Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50031cdb3 | ||
|
|
3b10e726b7 | ||
|
|
623935df22 | ||
|
|
afcdd7ef19 | ||
|
|
4553725d4e | ||
|
|
47b3a6d96e | ||
|
|
3de7e5faec | ||
|
|
fa3d2b932c | ||
|
|
d29eb5d844 | ||
|
|
c26904b61e | ||
|
|
9c9c46caf1 | ||
|
|
44a0016513 | ||
|
|
96ebbfd719 | ||
|
|
eadef33eac | ||
|
|
fe0bee4ce7 | ||
|
|
d52530c52c | ||
|
|
dc204577a8 | ||
|
|
5c0581a1da | ||
|
|
9235f68402 | ||
|
|
be4b1636c0 | ||
|
|
a63ef672bb | ||
|
|
1cde0c0544 | ||
|
|
04ad94ad3c | ||
|
|
4c42d9a7f9 |
@@ -24,6 +24,11 @@ R2_SECRET_ACCESS_KEY=
|
|||||||
R2_BUCKET=
|
R2_BUCKET=
|
||||||
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
||||||
R2_PUBLIC_URL=
|
R2_PUBLIC_URL=
|
||||||
|
# Separate bucket for private media (DM attachments), read only via short-lived
|
||||||
|
# presigned GETs. It must have NO public access and no custom domain: R2 public
|
||||||
|
# access is per-bucket, so a prefix inside R2_BUCKET would still be world-readable.
|
||||||
|
# Empty disables private-media endpoints (they return 503).
|
||||||
|
R2_PRIVATE_BUCKET=
|
||||||
|
|
||||||
# SMTP (password-reset + email-verification + /feedback admin notifications)
|
# SMTP (password-reset + email-verification + /feedback admin notifications)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|||||||
+10
-4
@@ -307,11 +307,17 @@ func indexVerses(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *
|
|||||||
TokenSeparators: &hyphenSeparators,
|
TokenSeparators: &hyphenSeparators,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tsClient.Collections().Create(context.Background(), schema)
|
// Create the collection; tolerate "already exists" so a --no-drop run can
|
||||||
if err != nil {
|
// upsert into the live collection without dropping it (matches indexProse).
|
||||||
sugar.Fatalf("[verses] Failed to create collection: %v", err)
|
if _, err := tsClient.Collections().Create(context.Background(), schema); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
sugar.Warnf("[verses] Create collection: %v (continuing — upserting into existing)", err)
|
||||||
|
} else {
|
||||||
|
sugar.Fatalf("[verses] Failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sugar.Info("[verses] Collection created")
|
||||||
}
|
}
|
||||||
sugar.Info("[verses] Collection created")
|
|
||||||
|
|
||||||
// Read from MongoDB
|
// Read from MongoDB
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ type Config struct {
|
|||||||
R2Bucket string
|
R2Bucket string
|
||||||
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
||||||
|
|
||||||
|
// R2PrivateBucket is a SEPARATE bucket with no public access, used for
|
||||||
|
// objects that must never be fetchable by URL alone (DM attachments).
|
||||||
|
// A prefix inside R2Bucket would not do: R2 public access is per-bucket,
|
||||||
|
// so anything in that bucket is world-readable. Empty disables the
|
||||||
|
// private-media endpoints (they return 503) — the BFF still boots.
|
||||||
|
R2PrivateBucket string
|
||||||
|
|
||||||
// SMTP — used for password-reset, email-verification, and
|
// SMTP — used for password-reset, email-verification, and
|
||||||
// /feedback admin-notification mails.
|
// /feedback admin-notification mails.
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
@@ -136,6 +143,7 @@ func Load() (*Config, error) {
|
|||||||
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
|
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
|
||||||
R2Bucket: getEnv("R2_BUCKET", ""),
|
R2Bucket: getEnv("R2_BUCKET", ""),
|
||||||
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
|
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
|
||||||
|
R2PrivateBucket: getEnv("R2_PRIVATE_BUCKET", ""),
|
||||||
|
|
||||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||||
|
|||||||
+134
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -46,6 +47,16 @@ func meta(total, limit, offset int) *response.Meta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats — GET /admin/stats → system snapshot for the dashboard home.
|
||||||
|
func (h *AdminHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
snap, err := h.svc.Stats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, snap)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
q, limit, offset := pageParams(r)
|
q, limit, offset := pageParams(r)
|
||||||
items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset)
|
items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset)
|
||||||
@@ -169,6 +180,27 @@ func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
|
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge
|
||||||
|
func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
// Already-acknowledged is a no-op success from the client's perspective.
|
||||||
|
if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFeedback — POST /admin/feedback/{id}/resolve
|
||||||
|
func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not resolve feedback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"resolved": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
|
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
|
||||||
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
|
||||||
reportType := chi.URLParam(r, "type")
|
reportType := chi.URLParam(r, "type")
|
||||||
@@ -177,7 +209,12 @@ func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id)
|
var in struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
// Body is optional/best-effort; an empty/missing note resolves with no note.
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||||
|
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id, strings.TrimSpace(in.Note))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "could not resolve report")
|
response.Error(w, http.StatusInternalServerError, "could not resolve report")
|
||||||
return
|
return
|
||||||
@@ -290,3 +327,99 @@ func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, ban
|
|||||||
}
|
}
|
||||||
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
|
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserLimits — POST /admin/users/{id}/limits with body
|
||||||
|
// { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }.
|
||||||
|
// Each field is optional: a null (or omitted) value resets that limit to the
|
||||||
|
// plan default; a number caps that resource for this user regardless of plan.
|
||||||
|
// ListDisciples — GET /admin/disciples?q=&limit= — options for the
|
||||||
|
// direct-disciple picker. Read-only; an empty q returns the first page
|
||||||
|
// alphabetically so the picker opens with content.
|
||||||
|
func (h *AdminHandler) ListDisciples(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
items, err := h.svc.SearchDisciples(r.Context(), r.URL.Query().Get("q"), limit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrDisciplesUnavailable) {
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not search disciples")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkDisciple — POST /admin/users/{id}/disciple {"disciple_id":"…"} — marks the
|
||||||
|
// account as one of Śrīla Prabhupāda's direct disciples. Re-posting with a
|
||||||
|
// different id moves the link.
|
||||||
|
func (h *AdminHandler) LinkDisciple(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var in struct {
|
||||||
|
DiscipleID string `json:"disciple_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.DiscipleID) == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "disciple_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch err := h.svc.LinkDisciple(r.Context(), id, in.DiscipleID); {
|
||||||
|
case err == nil:
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"linked": true})
|
||||||
|
case errors.Is(err, service.ErrAdminUserNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, "user not found")
|
||||||
|
case errors.Is(err, service.ErrDiscipleNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, "disciple not found")
|
||||||
|
case errors.Is(err, repository.ErrDiscipleClaimed):
|
||||||
|
response.Error(w, http.StatusConflict, "that disciple is already linked to another user")
|
||||||
|
case errors.Is(err, service.ErrDisciplesUnavailable):
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not link disciple")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkDisciple — DELETE /admin/users/{id}/disciple — removes the link.
|
||||||
|
func (h *AdminHandler) UnlinkDisciple(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
switch err := h.svc.UnlinkDisciple(r.Context(), id); {
|
||||||
|
case err == nil:
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"linked": false})
|
||||||
|
case errors.Is(err, service.ErrAdminUserNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, "user not found")
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not unlink disciple")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var in struct {
|
||||||
|
BookmarkLimit *int `json:"bookmark_limit"`
|
||||||
|
PublicCollectionLimit *int `json:"public_collection_limit"`
|
||||||
|
PrivateCollectionLimit *int `json:"private_collection_limit"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxLimit = 100000
|
||||||
|
for _, v := range []*int{in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit} {
|
||||||
|
if v != nil && (*v < 0 || *v > maxLimit) {
|
||||||
|
response.Error(w, http.StatusBadRequest, "each limit must be between 0 and 100000")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok, err := h.svc.SetUserLimits(r.Context(), id, in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not update limits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"updated": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListShortURLs — GET /admin/short-urls?q=&limit=&offset= — paginated list of
|
||||||
|
// shortened URLs, newest first.
|
||||||
|
func (h *AdminHandler) ListShortURLs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, limit, offset := pageParams(r)
|
||||||
|
items, total, err := h.svc.ListShortURLs(r.Context(), q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list short urls")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortURLStats — GET /admin/short-urls/stats?from=YYYY-MM-DD&to=YYYY-MM-DD —
|
||||||
|
// today/week/month/all-time counts, plus a custom range when from+to are given.
|
||||||
|
func (h *AdminHandler) ShortURLStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from, to := parseDateRange(r)
|
||||||
|
stats, err := h.svc.ShortURLStats(r.Context(), from, to)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load short url stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateRange reads ?from=YYYY-MM-DD&to=YYYY-MM-DD (UTC). `to` is treated as
|
||||||
|
// inclusive, so the returned upper bound is the exclusive start of the next
|
||||||
|
// day. Returns (nil, nil) unless both dates are present and valid.
|
||||||
|
func parseDateRange(r *http.Request) (*time.Time, *time.Time) {
|
||||||
|
fs := r.URL.Query().Get("from")
|
||||||
|
ts := r.URL.Query().Get("to")
|
||||||
|
if fs == "" || ts == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
from, err1 := time.ParseInLocation("2006-01-02", fs, time.UTC)
|
||||||
|
to, err2 := time.ParseInLocation("2006-01-02", ts, time.UTC)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
toExclusive := to.AddDate(0, 0, 1)
|
||||||
|
return &from, &toExclusive
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAudioProgressItems bounds a single upsert payload. A normal save
|
||||||
|
// sends one item; the on-login sync sends the user's whole local set,
|
||||||
|
// which is realistically small. The cap just guards against abuse.
|
||||||
|
const maxAudioProgressItems = 2000
|
||||||
|
|
||||||
|
// AudioProgressHandler serves the audio player's per-file resume points.
|
||||||
|
// All routes are auth-required; the user comes from the request context
|
||||||
|
// populated by middleware.RequireAuth.
|
||||||
|
type AudioProgressHandler struct {
|
||||||
|
svc *service.AudioProgressService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioProgressHandler(svc *service.AudioProgressService) *AudioProgressHandler {
|
||||||
|
return &AudioProgressHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type audioProgressItems struct {
|
||||||
|
Items []model.AudioProgress `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get — GET /users/me/audio-progress. Returns every saved resume point so
|
||||||
|
// the client can hydrate its in-memory map on sign-in.
|
||||||
|
func (h *AudioProgressHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
items, err := h.svc.List(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load audio progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []model.AudioProgress{}
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, audioProgressItems{Items: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put — PUT /users/me/audio-progress. Upserts a batch of resume points
|
||||||
|
// (one item for a normal save; N for the on-login sync). Newest-wins is
|
||||||
|
// enforced in SQL, so order/conflicts are safe.
|
||||||
|
func (h *AudioProgressHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
var body audioProgressItems
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect { items: [...] })")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Items) > maxAudioProgressItems {
|
||||||
|
response.Error(w, http.StatusBadRequest, "too many items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := make([]model.AudioProgress, 0, len(body.Items))
|
||||||
|
for _, it := range body.Items {
|
||||||
|
if it.Key == "" || it.Position < 0 {
|
||||||
|
continue // drop malformed entries rather than fail the batch
|
||||||
|
}
|
||||||
|
if it.UpdatedAt.IsZero() {
|
||||||
|
it.UpdatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
clean = append(clean, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Upsert(r.Context(), uid, clean); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not save audio progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete — DELETE /users/me/audio-progress?key=<url-encoded key>. Removes a
|
||||||
|
// single resume point (used when a track plays to completion). The key is
|
||||||
|
// a URL, so it travels as a query param to avoid path-encoding issues.
|
||||||
|
func (h *AudioProgressHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "missing key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(r.Context(), uid, key); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not delete audio progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelRule authorizes one family of channels.
|
||||||
|
//
|
||||||
|
// Suffix is whatever follows Prefix in the channel name (a user ID today, a
|
||||||
|
// conversation ID once chat lands). Authorize reports whether userID — the
|
||||||
|
// authenticated caller — may subscribe. Returning an error means "couldn't
|
||||||
|
// decide" (a lookup failed) and surfaces as a 500; returning (false, nil)
|
||||||
|
// means "decided: no" and surfaces as a 403.
|
||||||
|
type ChannelRule struct {
|
||||||
|
Prefix string
|
||||||
|
Authorize func(ctx context.Context, userID, suffix string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastingAuthHandler implements the Pusher channel-authorization
|
||||||
|
// endpoint that pusher-js calls (as `authEndpoint`) before subscribing to a
|
||||||
|
// private- or presence- channel.
|
||||||
|
//
|
||||||
|
// This is the gate that makes private channels private: Soketi will not honour
|
||||||
|
// a subscription without a signature, and the signature is only minted here,
|
||||||
|
// after RequireAuth has established who the caller is and a rule has agreed
|
||||||
|
// they may listen. Unknown channels are denied by default, so adding a channel
|
||||||
|
// family is an explicit act.
|
||||||
|
type BroadcastingAuthHandler struct {
|
||||||
|
rt *realtime.SoketiClient
|
||||||
|
rules []ChannelRule
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBroadcastingAuthHandler(rt *realtime.SoketiClient, logger *zap.Logger, rules ...ChannelRule) *BroadcastingAuthHandler {
|
||||||
|
return &BroadcastingAuthHandler{rt: rt, rules: rules, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsRule allows a user to subscribe to their own notification
|
||||||
|
// channel and nobody else's.
|
||||||
|
func NotificationsRule() ChannelRule {
|
||||||
|
return ChannelRule{
|
||||||
|
Prefix: "private-notifications.",
|
||||||
|
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
|
||||||
|
return suffix != "" && suffix == userID, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationRule allows a caller to subscribe to a thread's channel only
|
||||||
|
// while they are a participant. isParticipant is injected rather than taking a
|
||||||
|
// repository dependency here, so the authorization check stays the service's
|
||||||
|
// single source of truth.
|
||||||
|
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
|
||||||
|
return ChannelRule{
|
||||||
|
Prefix: "private-conversation.",
|
||||||
|
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
|
||||||
|
if suffix == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return isParticipant(ctx, userID, suffix)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type broadcastingAuthResp struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
ChannelData string `json:"channel_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAuth emits the auth payload BARE, deliberately bypassing the API's
|
||||||
|
// {success, data} envelope.
|
||||||
|
//
|
||||||
|
// This endpoint speaks Pusher's protocol, not our API's: pusher-js reads
|
||||||
|
// `auth` off the top level of the parsed body, so an enveloped response makes
|
||||||
|
// every private subscription fail. Errors still use response.Error — the
|
||||||
|
// client only checks the status code on that path.
|
||||||
|
func writeAuth(w http.ResponseWriter, payload broadcastingAuthResp) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle — POST /api/v1/broadcasting/auth. Session-authenticated (the cookie
|
||||||
|
// travels because the frontend sets credentials: "include").
|
||||||
|
//
|
||||||
|
// pusher-js posts application/x-www-form-urlencoded by default; JSON is
|
||||||
|
// accepted too so a custom client isn't forced into form encoding.
|
||||||
|
func (h *BroadcastingAuthHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
if userID == "" {
|
||||||
|
// RequireAuth should have caught this; refuse rather than sign.
|
||||||
|
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socketID, channel := parseBroadcastingAuthReq(r)
|
||||||
|
if socketID == "" || channel == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "socket_id and channel_name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, suffix, found := h.match(channel)
|
||||||
|
if !found {
|
||||||
|
// Default-deny. An unrecognised channel is a client bug or an
|
||||||
|
// probe — either way there's nothing to sign.
|
||||||
|
h.logger.Warn("broadcasting auth: unknown channel",
|
||||||
|
zap.String("channel", channel), zap.String("user_id", userID))
|
||||||
|
response.Error(w, http.StatusForbidden, "channel not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := rule.Authorize(r.Context(), userID, suffix)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("broadcasting auth: authorize failed",
|
||||||
|
zap.String("channel", channel), zap.Error(err))
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not authorize channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
h.logger.Warn("broadcasting auth: denied",
|
||||||
|
zap.String("channel", channel), zap.String("user_id", userID))
|
||||||
|
response.Error(w, http.StatusForbidden, "not permitted on this channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(channel, "presence-") {
|
||||||
|
auth, channelData, err := h.rt.AuthorizePresence(socketID, channel, realtime.PresenceData{UserID: userID})
|
||||||
|
if err != nil {
|
||||||
|
h.authError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAuth(w, broadcastingAuthResp{Auth: auth, ChannelData: channelData})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := h.rt.AuthorizeChannel(socketID, channel)
|
||||||
|
if err != nil {
|
||||||
|
h.authError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAuth(w, broadcastingAuthResp{Auth: auth})
|
||||||
|
}
|
||||||
|
|
||||||
|
// match finds the rule owning this channel and returns the part of the name
|
||||||
|
// after its prefix.
|
||||||
|
func (h *BroadcastingAuthHandler) match(channel string) (ChannelRule, string, bool) {
|
||||||
|
for _, rule := range h.rules {
|
||||||
|
if strings.HasPrefix(channel, rule.Prefix) {
|
||||||
|
return rule, strings.TrimPrefix(channel, rule.Prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ChannelRule{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BroadcastingAuthHandler) authError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, realtime.ErrNotConfigured) {
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, "realtime is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("broadcasting auth: signing failed", zap.Error(err))
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBroadcastingAuthReq reads socket_id / channel_name from either a form
|
||||||
|
// body (pusher-js default) or a JSON body.
|
||||||
|
func parseBroadcastingAuthReq(r *http.Request) (socketID, channel string) {
|
||||||
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||||
|
var body struct {
|
||||||
|
SocketID string `json:"socket_id"`
|
||||||
|
ChannelName string `json:"channel_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(body.SocketID), strings.TrimSpace(body.ChannelName)
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.PostFormValue("socket_id")), strings.TrimSpace(r.PostFormValue("channel_name"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testUser = "user-a"
|
||||||
|
|
||||||
|
func testAuthHandler(t *testing.T) *BroadcastingAuthHandler {
|
||||||
|
t.Helper()
|
||||||
|
rt := realtime.NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop())
|
||||||
|
if rt == nil {
|
||||||
|
t.Fatal("realtime client is nil")
|
||||||
|
}
|
||||||
|
// A second rule stands in for the chat conversation channels, proving the
|
||||||
|
// rule table is what decides access rather than anything hardcoded.
|
||||||
|
convRule := ChannelRule{
|
||||||
|
Prefix: "presence-conversation.",
|
||||||
|
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
|
||||||
|
return suffix == "open-to-user-a" && userID == testUser, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return NewBroadcastingAuthHandler(rt, zap.NewNop(), NotificationsRule(), convRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve posts a form-encoded auth request as userID ("" = unauthenticated).
|
||||||
|
func serve(t *testing.T, h *BroadcastingAuthHandler, userID, socketID, channel string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"socket_id": {socketID}, "channel_name": {channel}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if userID != "" {
|
||||||
|
req = req.WithContext(middleware.WithUserID(req.Context(), userID))
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Handle(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_Authorization(t *testing.T) {
|
||||||
|
h := testAuthHandler(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID string
|
||||||
|
channel string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"own notifications channel", testUser, "private-notifications." + testUser, http.StatusOK},
|
||||||
|
{"another user's notifications", testUser, "private-notifications.user-b", http.StatusForbidden},
|
||||||
|
{"unauthenticated", "", "private-notifications." + testUser, http.StatusUnauthorized},
|
||||||
|
{"unknown channel family", testUser, "private-secrets.42", http.StatusForbidden},
|
||||||
|
{"legacy public channel is not signable", testUser, "notifications." + testUser, http.StatusForbidden},
|
||||||
|
{"empty suffix", testUser, "private-notifications.", http.StatusForbidden},
|
||||||
|
{"presence channel allowed by rule", testUser, "presence-conversation.open-to-user-a", http.StatusOK},
|
||||||
|
{"presence channel denied by rule", testUser, "presence-conversation.someone-elses", http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rec := serve(t, h, tc.userID, "1234.5678", tc.channel)
|
||||||
|
if rec.Code != tc.want {
|
||||||
|
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
|
||||||
|
// with the victim's id but is a different channel.
|
||||||
|
// ConversationRule delegates to the chat service's membership check, so the
|
||||||
|
// realtime channel and the REST endpoints can never disagree about who is in a
|
||||||
|
// thread.
|
||||||
|
func TestConversationRule(t *testing.T) {
|
||||||
|
const member, outsider = "member-1", "outsider-1"
|
||||||
|
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
|
||||||
|
if conversationID == "boom" {
|
||||||
|
return false, errors.New("database down")
|
||||||
|
}
|
||||||
|
return userID == member && conversationID == "conv-1", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if rule.Prefix != "private-conversation." {
|
||||||
|
t.Errorf("prefix = %q", rule.Prefix)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user string
|
||||||
|
suffix string
|
||||||
|
wantOK bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"participant", member, "conv-1", true, false},
|
||||||
|
{"outsider", outsider, "conv-1", false, false},
|
||||||
|
{"participant of another thread", member, "conv-2", false, false},
|
||||||
|
{"empty conversation id", member, "", false, false},
|
||||||
|
{"lookup failure is not a denial", member, "boom", false, true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
|
||||||
|
if ok != tc.wantOK || (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
|
||||||
|
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_SignatureShape(t *testing.T) {
|
||||||
|
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
ChannelData string `json:"channel_data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(body.Auth, "test-key:") {
|
||||||
|
t.Errorf("auth = %q, want a <key>:<signature> pair", body.Auth)
|
||||||
|
}
|
||||||
|
// channel_data belongs to presence channels only; sending it for a private
|
||||||
|
// channel would make Soketi reject the subscription.
|
||||||
|
if body.ChannelData != "" {
|
||||||
|
t.Errorf("channel_data = %q, want empty for a private channel", body.ChannelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_PresenceReturnsChannelData(t *testing.T) {
|
||||||
|
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "presence-conversation.open-to-user-a")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ChannelData string `json:"channel_data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
if !strings.Contains(body.ChannelData, `"user_id":"`+testUser+`"`) {
|
||||||
|
t.Errorf("channel_data = %q, want the caller's user_id", body.ChannelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_AcceptsJSONBody(t *testing.T) {
|
||||||
|
body := `{"socket_id":"1234.5678","channel_name":"private-notifications.` + testUser + `"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = req.WithContext(middleware.WithUserID(req.Context(), testUser))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
testAuthHandler(t).Handle(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastingAuth_MissingParams(t *testing.T) {
|
||||||
|
h := testAuthHandler(t)
|
||||||
|
if rec := serve(t, h, testUser, "", "private-notifications."+testUser); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("missing socket_id: status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := serve(t, h, testUser, "1234.5678", ""); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("missing channel_name: status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With realtime disabled there is no secret to sign with; the endpoint must
|
||||||
|
// say so rather than hand back an unusable signature.
|
||||||
|
func TestBroadcastingAuth_UnconfiguredRealtime(t *testing.T) {
|
||||||
|
h := NewBroadcastingAuthHandler(nil, zap.NewNop(), NotificationsRule())
|
||||||
|
if rec := serve(t, h, testUser, "1234.5678", "private-notifications."+testUser); rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatHandler exposes the chat inbox, threads and messages.
|
||||||
|
//
|
||||||
|
// Every route is session-authenticated and every read re-checks membership in
|
||||||
|
// the service, so a conversation id is not a capability: knowing one gets you
|
||||||
|
// nothing if you were never in the thread (or have left it).
|
||||||
|
type ChatHandler struct {
|
||||||
|
svc *service.ChatService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} }
|
||||||
|
|
||||||
|
// writeChatError maps service errors onto status codes. Not-a-participant is a
|
||||||
|
// 404, not a 403: confirming a thread exists to someone outside it leaks that
|
||||||
|
// two people are talking.
|
||||||
|
func writeChatError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone):
|
||||||
|
response.Error(w, http.StatusNotFound, "conversation not found")
|
||||||
|
case errors.Is(err, service.ErrAvatarUnset):
|
||||||
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatBadAvatarKey):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrMessageNotHere):
|
||||||
|
response.Error(w, http.StatusNotFound, "message not found")
|
||||||
|
case errors.Is(err, repository.ErrAttachmentGone):
|
||||||
|
response.Error(w, http.StatusNotFound, "attachment not found")
|
||||||
|
case errors.Is(err, service.ErrChatMediaOff):
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatBadMedia):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable),
|
||||||
|
errors.Is(err, service.ErrChatAdminsOnly):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole),
|
||||||
|
errors.Is(err, service.ErrChatBadPolicy):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatNotSender):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatNotAccepting):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge),
|
||||||
|
errors.Is(err, service.ErrChatGenderUnset):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatBlocked):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatReadOnly):
|
||||||
|
response.Error(w, http.StatusConflict, "Kautilya is not available yet")
|
||||||
|
case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong),
|
||||||
|
errors.Is(err, service.ErrChatBadMuteDuration),
|
||||||
|
errors.Is(err, repository.ErrReplyTargetNotHere):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "chat request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox — GET /chat/conversations?tab=chats|kautilya
|
||||||
|
func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
tab := r.URL.Query().Get("tab")
|
||||||
|
items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
type openDMReq struct {
|
||||||
|
// Handle, not id: profiles are addressed by handle everywhere else, and
|
||||||
|
// the public profile payload carries no user id.
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
|
||||||
|
// thread when there is one, so the client can call it every time it opens a
|
||||||
|
// profile without checking first.
|
||||||
|
func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
var req openDMReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "handle is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, conv)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createGroupReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
MemberHandles []string `json:"member_handles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup — POST /chat/conversations/group
|
||||||
|
func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
var req createGroupReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, conv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIThread — POST /chat/conversations/kautilya
|
||||||
|
func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, conv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail — GET /chat/conversations/{id}
|
||||||
|
func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages — GET /chat/conversations/{id}/messages?before_seq=&limit=
|
||||||
|
//
|
||||||
|
// before_seq is an exclusive upper bound rather than an offset, so paging stays
|
||||||
|
// stable while new messages keep arriving at the top.
|
||||||
|
func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
convID := chi.URLParam(r, "id")
|
||||||
|
msgs, err := h.svc.Messages(r.Context(), userID, convID,
|
||||||
|
int64(atoiOr(r.URL.Query().Get("before_seq"), 0)),
|
||||||
|
atoiOr(r.URL.Query().Get("limit"), 0))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mediaReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Mime string `json:"mime"`
|
||||||
|
SizeBytes *int64 `json:"size_bytes"`
|
||||||
|
Width *int `json:"width"`
|
||||||
|
Height *int `json:"height"`
|
||||||
|
DurationMs *int `json:"duration_ms"`
|
||||||
|
Blurhash *string `json:"blurhash"`
|
||||||
|
FileName *string `json:"file_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sendReq struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
ClientMsgID *string `json:"client_msg_id"`
|
||||||
|
// preview_url is the link the sender chose to keep a card for; omitted
|
||||||
|
// when they dismissed it. Only the URL — never the card's contents.
|
||||||
|
PreviewURL *string `json:"preview_url"`
|
||||||
|
Attachments []mediaReq `json:"attachments"`
|
||||||
|
ReplyToID *string `json:"reply_to_id"`
|
||||||
|
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
|
||||||
|
// its own "can the caller see the source?" check in a later phase, and
|
||||||
|
// accepting an unvalidated message reference in the meantime would let a
|
||||||
|
// client point at any message in the system.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send — POST /chat/conversations/{id}/messages
|
||||||
|
func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
convID := chi.URLParam(r, "id")
|
||||||
|
var req sendReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
media := make([]service.MediaAttachment, 0, len(req.Attachments))
|
||||||
|
for _, a := range req.Attachments {
|
||||||
|
media = append(media, service.MediaAttachment{
|
||||||
|
Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width,
|
||||||
|
Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash,
|
||||||
|
FileName: a.FileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
|
||||||
|
Body: req.Body,
|
||||||
|
ClientMsgID: req.ClientMsgID,
|
||||||
|
ReplyToID: req.ReplyToID,
|
||||||
|
PreviewURL: req.PreviewURL,
|
||||||
|
Attachments: media,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings — GET /chat/settings
|
||||||
|
func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatSettingsReq struct {
|
||||||
|
ReadReceipts *bool `json:"read_receipts"`
|
||||||
|
WhoCanMessage *string `json:"who_can_message"`
|
||||||
|
MinAccountAgeMonths *int `json:"min_account_age_months"`
|
||||||
|
TrainKautilya *bool `json:"train_kautilya"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings — PATCH /chat/settings
|
||||||
|
//
|
||||||
|
// Fields are optional: anything omitted keeps its current value, so a toggle
|
||||||
|
// doesn't have to send the whole object back.
|
||||||
|
func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserID(r.Context())
|
||||||
|
current, err := h.svc.Settings(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatSettingsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ReadReceipts != nil {
|
||||||
|
current.ReadReceipts = *req.ReadReceipts
|
||||||
|
}
|
||||||
|
if req.WhoCanMessage != nil {
|
||||||
|
current.WhoCanMessage = *req.WhoCanMessage
|
||||||
|
}
|
||||||
|
if req.MinAccountAgeMonths != nil {
|
||||||
|
current.MinAccountAgeMonths = *req.MinAccountAgeMonths
|
||||||
|
}
|
||||||
|
if req.TrainKautilya != nil {
|
||||||
|
current.TrainKautilya = *req.TrainKautilya
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := h.svc.SaveSettings(r.Context(), userID, current)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkPreviewReq struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkPreview — POST /chat/link-preview
|
||||||
|
//
|
||||||
|
// The composer calls this as a link is typed so the sender can see the card
|
||||||
|
// and dismiss it before sending. Only the URL travels in either direction's
|
||||||
|
// trust boundary: the BFF fetches the metadata itself.
|
||||||
|
func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req linkPreviewReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview, err := h.svc.LinkPreview(r.Context(), req.URL)
|
||||||
|
if err != nil {
|
||||||
|
// No card is a normal outcome, not a server fault.
|
||||||
|
response.Error(w, http.StatusUnprocessableEntity, "no preview available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatPresignReq struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignAttachment — POST /chat/conversations/{id}/attachments/presign
|
||||||
|
func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req chatPresignReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, key, err := h.svc.PresignAttachment(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The key comes back so the client can name it when it sends the message;
|
||||||
|
// there is no public URL, by design.
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment — GET /chat/attachments/{attachmentID}
|
||||||
|
//
|
||||||
|
// A stable URL that redirects to a short-lived signed one. Membership is
|
||||||
|
// re-checked on every fetch, so access revoked after a link was shared stops
|
||||||
|
// working — which a signed URL baked into the message payload could not do.
|
||||||
|
func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
url, err := h.svc.AttachmentURL(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Private and short-lived: never let a shared cache hold onto it.
|
||||||
|
w.Header().Set("Cache-Control", "private, no-store")
|
||||||
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign
|
||||||
|
func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req chatPresignReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, key, err := h.svc.PresignGroupAvatar(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupAvatar — GET /chat/conversations/{id}/avatar
|
||||||
|
//
|
||||||
|
// Stable URL that redirects to a short-lived signed one, re-checking
|
||||||
|
// membership each time, exactly as message attachments do.
|
||||||
|
func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
url, err := h.svc.GroupAvatarURL(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "private, no-store")
|
||||||
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before=
|
||||||
|
func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var before *time.Time
|
||||||
|
if raw := r.URL.Query().Get("before"); raw != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||||
|
before = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before,
|
||||||
|
atoiOr(r.URL.Query().Get("limit"), 0))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starred — GET /chat/conversations/{id}/starred
|
||||||
|
func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) {
|
||||||
|
msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star
|
||||||
|
func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setStar(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setStar(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) {
|
||||||
|
err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone
|
||||||
|
func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := r.URL.Query().Get("scope")
|
||||||
|
if scope == "" {
|
||||||
|
scope = service.DeleteForMe // the safer default
|
||||||
|
}
|
||||||
|
err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "messageID"), scope)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type forwardReq struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward — POST /chat/conversations/{id}/forward
|
||||||
|
//
|
||||||
|
// The path names the DESTINATION; the body names the message being copied in.
|
||||||
|
func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req forwardReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "message_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
req.MessageID, chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatReportReq struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report
|
||||||
|
func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req chatReportReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "messageID"), req.Category, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type membersReq struct {
|
||||||
|
// Handles, not ids: search results and follow lists identify people by
|
||||||
|
// handle, and it matches /u/{handle}/block elsewhere in the API.
|
||||||
|
MemberHandles []string `json:"member_handles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMembers — POST /chat/conversations/{id}/members
|
||||||
|
func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req membersReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), req.MemberHandles); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember — DELETE /chat/conversations/{id}/members/{handle}
|
||||||
|
//
|
||||||
|
// Passing your own handle is how you leave.
|
||||||
|
func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleReq struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberRole — PATCH /chat/conversations/{id}/members/{userID}
|
||||||
|
func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req roleReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateGroupReq struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
SendPolicy *string `json:"send_policy"`
|
||||||
|
// AvatarKey comes from PresignGroupAvatar; the service checks it belongs
|
||||||
|
// to this group before storing it.
|
||||||
|
AvatarKey *string `json:"avatar_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup — PATCH /chat/conversations/{id}
|
||||||
|
func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req updateGroupReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type receiptReq struct {
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRead — POST /chat/conversations/{id}/read
|
||||||
|
func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.receipt(w, r, h.svc.MarkRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDelivered — POST /chat/conversations/{id}/delivered
|
||||||
|
func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.receipt(w, r, h.svc.MarkDelivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) {
|
||||||
|
var req receiptReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type muteReq struct {
|
||||||
|
// Duration is one of 8h, 1w, always, off.
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMute — POST /chat/conversations/{id}/mute
|
||||||
|
//
|
||||||
|
// Muting silences alerts for the caller only; the thread keeps receiving
|
||||||
|
// messages and its unread count keeps climbing.
|
||||||
|
func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req muteReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipts — GET /chat/conversations/{id}/receipts
|
||||||
|
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiOr(s string, fallback int) int {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -9,10 +9,33 @@ import (
|
|||||||
|
|
||||||
type DiscipleHandler struct {
|
type DiscipleHandler struct {
|
||||||
repo *repository.DiscipleRepo
|
repo *repository.DiscipleRepo
|
||||||
|
// users (Postgres) resolves which disciples have a platform account
|
||||||
|
// linked by an admin. Separate store from the disciple records
|
||||||
|
// themselves, hence the second dependency.
|
||||||
|
users *repository.UserRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler {
|
func NewDiscipleHandler(repo *repository.DiscipleRepo, users *repository.UserRepo) *DiscipleHandler {
|
||||||
return &DiscipleHandler{repo: repo}
|
return &DiscipleHandler{repo: repo, users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — GET /disciples/linked — every disciple that has a platform account,
|
||||||
|
// as {disciple_id, handle, name, avatar_url}. Public and unpaged: only Śrīla
|
||||||
|
// Prabhupāda's own disciples are ever linked, so the set is tiny and the
|
||||||
|
// /disciples browser fetches it once to highlight the matching cards.
|
||||||
|
func (h *DiscipleHandler) Linked(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.users == nil {
|
||||||
|
// Degraded boot without Postgres — an empty set just means "no
|
||||||
|
// highlights", which is a fine reading experience.
|
||||||
|
response.JSON(w, http.StatusOK, []repository.DiscipleLink{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
links, err := h.users.ListDiscipleLinks(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "failed to fetch linked disciples")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, links)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -68,3 +68,21 @@ func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
response.JSON(w, http.StatusCreated, f)
|
response.JSON(w, http.StatusCreated, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mine returns the authenticated caller's own feedback submissions,
|
||||||
|
// newest first — each with its status (acknowledged / resolved) and any
|
||||||
|
// admin reply. Registered under RequireAuth, so UserID is always set;
|
||||||
|
// the guard is belt-and-suspenders.
|
||||||
|
func (h *FeedbackHandler) Mine(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if uid == "" {
|
||||||
|
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.ListForUser(r.Context(), uid, 50)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load your feedback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,11 +203,20 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
|||||||
prioritizeTokenPos := true
|
prioritizeTokenPos := true
|
||||||
prefix, numTypos := fuzzinessKnobs(exact)
|
prefix, numTypos := fuzzinessKnobs(exact)
|
||||||
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
||||||
|
// Merge-paginate: every sub-search fetches the first page*limit hits (page 1,
|
||||||
|
// a wide window) so the merged top page*limit is fully covered; we slice the
|
||||||
|
// requested page out after sorting below. Asking each collection for its OWN
|
||||||
|
// page N instead would miss globally-ranked hits and go blank past page 1
|
||||||
|
// (e.g. a sparse query whose few hits all sit in each collection's page 1).
|
||||||
|
window := page * limit
|
||||||
|
if window > 250 { // Typesense per_page ceiling
|
||||||
|
window = 250
|
||||||
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
queryBy := collectionQueryByFields[col]
|
queryBy := collectionQueryByFields[col]
|
||||||
weights := collectionQueryByWeights[col]
|
weights := collectionQueryByWeights[col]
|
||||||
perPage := limit
|
perPage := window
|
||||||
pg := page
|
pg := 1
|
||||||
params := api.MultiSearchCollectionParameters{
|
params := api.MultiSearchCollectionParameters{
|
||||||
Collection: col,
|
Collection: col,
|
||||||
Q: stringPtr(query),
|
Q: stringPtr(query),
|
||||||
@@ -262,14 +271,18 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
|||||||
return allItems[i].score > allItems[j].score
|
return allItems[i].score > allItems[j].score
|
||||||
})
|
})
|
||||||
|
|
||||||
// Paginate the merged results: since each sub-search already returned `limit`
|
// Slice the requested page out of the merged ranking. Collections hold
|
||||||
// results for the given page, the merged set has at most 9*limit items.
|
// disjoint documents, so totalFound (sum of per-collection founds) is the
|
||||||
// We take the top `limit` after sorting.
|
// exact total and start past the end means the page is genuinely empty.
|
||||||
if len(allItems) > limit {
|
start := (page - 1) * limit
|
||||||
allItems = allItems[:limit]
|
if start >= len(allItems) {
|
||||||
|
return []searchResultItem{}, totalFound
|
||||||
}
|
}
|
||||||
|
end := start + limit
|
||||||
return allItems, totalFound
|
if end > len(allItems) {
|
||||||
|
end = len(allItems)
|
||||||
|
}
|
||||||
|
return allItems[start:end], totalFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// singleSearch searches a single collection.
|
// singleSearch searches a single collection.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
@@ -258,6 +259,17 @@ func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) {
|
|||||||
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)
|
||||||
|
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||||
|
// cursor we keep the offset+meta path the profile grid relies on.
|
||||||
|
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||||
|
posts, err := h.svc.ListMineKeyset(r.Context(), uid, beforeAt, beforeID, limit)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, posts)
|
||||||
|
return
|
||||||
|
}
|
||||||
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
|
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
mapSocialErr(w, err)
|
||||||
@@ -666,6 +678,7 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
"handle": u.Handle,
|
"handle": u.Handle,
|
||||||
"name": u.Name,
|
"name": u.Name,
|
||||||
"avatar_url": u.AvatarURL,
|
"avatar_url": u.AvatarURL,
|
||||||
|
"bio": u.Bio,
|
||||||
"joined_at": u.CreatedAt,
|
"joined_at": u.CreatedAt,
|
||||||
"country": u.Country,
|
"country": u.Country,
|
||||||
"post_count": count,
|
"post_count": count,
|
||||||
@@ -674,6 +687,11 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
"following_count": following,
|
"following_count": following,
|
||||||
"relationship": rel,
|
"relationship": rel,
|
||||||
"public_collections": cols,
|
"public_collections": cols,
|
||||||
|
// True when an admin has linked this account to a record in the
|
||||||
|
// disciples directory — i.e. one of Śrīla Prabhupāda's own initiated
|
||||||
|
// disciples. Drives the badge on the profile header. Only the boolean
|
||||||
|
// is exposed; the underlying Mongo id stays internal.
|
||||||
|
"is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +737,22 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
|||||||
// gateByBlock already resolved the owner, so list by id rather than
|
// gateByBlock already resolved the owner, so list by id rather than
|
||||||
// re-resolving the handle. Hidden posts never appear here (they live in
|
// re-resolving the handle. Hidden posts never appear here (they live in
|
||||||
// the owner's separate Hidden tab).
|
// the owner's separate Hidden tab).
|
||||||
|
//
|
||||||
|
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||||
|
// cursor we keep the offset+meta path the profile grid relies on.
|
||||||
|
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||||
|
posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.hydrateFeed(r, posts); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, posts)
|
||||||
|
return
|
||||||
|
}
|
||||||
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
mapSocialErr(w, err)
|
||||||
@@ -835,6 +869,21 @@ func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IncomingFollowRequests — GET /follow-requests?limit=&offset=&q=. The caller's
|
||||||
|
// pending incoming follow requests (auth required), for the notifications
|
||||||
|
// "Friend Requests" tab where they're accepted/rejected.
|
||||||
|
func (h *ProfileHandler) IncomingFollowRequests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
users, total, err := h.graph.IncomingRequests(r.Context(), uid, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// shared helpers
|
// shared helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -881,6 +930,22 @@ func parseLimitOffset(r *http.Request) (limit, offset int) {
|
|||||||
return limit, offset
|
return limit, offset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCursor reads an optional keyset cursor for the scroll feed: `before` is
|
||||||
|
// the last seen post's created_at (RFC3339Nano) and `before_id` its id. ok is
|
||||||
|
// false when either is missing or unparseable (callers then page by offset).
|
||||||
|
func parseCursor(r *http.Request) (beforeAt time.Time, beforeID string, ok bool) {
|
||||||
|
at := strings.TrimSpace(r.URL.Query().Get("before"))
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("before_id"))
|
||||||
|
if at == "" || id == "" {
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, at)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
return t, id, true
|
||||||
|
}
|
||||||
|
|
||||||
func paginationMeta(total, limit, offset int) *response.Meta {
|
func paginationMeta(total, limit, offset int) *response.Meta {
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
|
|||||||
@@ -10,14 +10,26 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// notifChannelPrefix is the public per-user channel the frontend subscribes to
|
// userIDFromNotifChannel extracts the user id from a per-user notifications
|
||||||
// for realtime notifications. Because it's keyed by user, a `channel_vacated`
|
// channel, matching both the private name and the legacy public one.
|
||||||
// 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.
|
// Because these channels are keyed by user, a `channel_vacated` webhook on one
|
||||||
const notifChannelPrefix = "notifications."
|
// means that user's *last* connection dropped — i.e. they went offline across
|
||||||
|
// all their tabs — so we can evict their feed seen-set. Both names are matched
|
||||||
|
// for the duration of the private-channel migration: clients running a
|
||||||
|
// service-worker-cached bundle still sit on the legacy channel, and matching
|
||||||
|
// only one name would silently stop evicting for half the users.
|
||||||
|
func userIDFromNotifChannel(channel string) (string, bool) {
|
||||||
|
if uid, ok := strings.CutPrefix(channel, realtime.UserChannelPrefix); ok {
|
||||||
|
return uid, uid != ""
|
||||||
|
}
|
||||||
|
uid, ok := strings.CutPrefix(channel, realtime.LegacyUserChannelPrefix)
|
||||||
|
return uid, ok && uid != ""
|
||||||
|
}
|
||||||
|
|
||||||
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
|
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
|
||||||
// signs each delivery with the app secret (X-Pusher-Signature = hex
|
// signs each delivery with the app secret (X-Pusher-Signature = hex
|
||||||
@@ -83,7 +95,7 @@ func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e.Name != "channel_vacated" {
|
if e.Name != "channel_vacated" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if uid, ok := strings.CutPrefix(e.Channel, notifChannelPrefix); ok && uid != "" {
|
if uid, ok := userIDFromNotifChannel(e.Channel); ok {
|
||||||
h.feed.Reset(ctx, uid)
|
h.feed.Reset(ctx, uid)
|
||||||
cleared++
|
cleared++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Both channel names must resolve during the private-channel migration:
|
||||||
|
// matching only one would silently stop evicting feed seen-sets for every
|
||||||
|
// client still on the other name.
|
||||||
|
func TestUserIDFromNotifChannel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantID string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"private-notifications.user-a", "user-a", true},
|
||||||
|
{"notifications.user-a", "user-a", true},
|
||||||
|
{"private-notifications.", "", false},
|
||||||
|
{"notifications.", "", false},
|
||||||
|
{"presence-conversation.c1", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
// Guards against a naive suffix split: the private name must not be
|
||||||
|
// mistaken for the legacy one and yield a mangled id.
|
||||||
|
{"private-notifications.notifications.x", "notifications.x", true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
gotID, gotOK := userIDFromNotifChannel(tc.channel)
|
||||||
|
if gotOK != tc.wantOK || (tc.wantOK && gotID != tc.wantID) {
|
||||||
|
t.Errorf("userIDFromNotifChannel(%q) = (%q, %v), want (%q, %v)",
|
||||||
|
tc.channel, gotID, gotOK, tc.wantID, tc.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-48
@@ -1,14 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatsHandler renders a single-page HTML snapshot of the system —
|
// StatsHandler renders a single-page HTML snapshot of the system —
|
||||||
@@ -17,17 +14,21 @@ import (
|
|||||||
// public per current product call. If that ever needs to change, wrap
|
// public per current product call. If that ever needs to change, wrap
|
||||||
// the route in middleware in server.go.
|
// the route in middleware in server.go.
|
||||||
//
|
//
|
||||||
// It also handles the "Mark resolved" form POST per feedback row, which
|
// It is READ-ONLY: feedback acknowledge/resolve actions live in the
|
||||||
// stamps resolved_at (one-way) and fires the author thank-you email via
|
// authenticated admin panel, never on this public page.
|
||||||
// the feedback service.
|
//
|
||||||
|
// PRIVACY: because the page is public, it must never render personal contact
|
||||||
|
// details. Anonymous submitters' email addresses are shown as "reply address
|
||||||
|
// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so
|
||||||
|
// the address isn't in memory here to leak by accident. Anything added to this
|
||||||
|
// template needs the same scrutiny; the admin panel is the place for PII.
|
||||||
type StatsHandler struct {
|
type StatsHandler struct {
|
||||||
stats *repository.StatsRepo
|
stats *repository.StatsRepo
|
||||||
feedback *repository.FeedbackRepo
|
feedback *repository.FeedbackRepo
|
||||||
svc *service.FeedbackService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
|
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
|
||||||
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
|
return &StatsHandler{stats: stats, feedback: feedback}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -55,33 +56,6 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
|
|
||||||
// the row acknowledged (one-way) and emails the author that we've seen
|
|
||||||
// it and started work, then 303-redirects back to /stats. Already-
|
|
||||||
// acknowledged or unknown ids redirect silently.
|
|
||||||
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
|
|
||||||
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
|
|
||||||
// the row resolved (one-way) and fires the author thank-you email, then
|
|
||||||
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
|
|
||||||
// resolved or unknown ids redirect silently — the desired end state is
|
|
||||||
// already true.
|
|
||||||
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
|
|
||||||
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
|
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
|
||||||
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||||
}).Parse(statsHTML))
|
}).Parse(statsHTML))
|
||||||
@@ -115,6 +89,7 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
|
|||||||
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
|
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
|
||||||
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
|
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
|
||||||
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
|
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
|
||||||
|
.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 }
|
||||||
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||||
.resolve-btn:hover { background: #196b40 }
|
.resolve-btn:hover { background: #196b40 }
|
||||||
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||||
@@ -158,8 +133,8 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
|
|||||||
<div class="author">
|
<div class="author">
|
||||||
{{if .AuthorHandle}}
|
{{if .AuthorHandle}}
|
||||||
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
|
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
|
||||||
{{else if .ContactEmail}}
|
{{else if .HasContactEmail}}
|
||||||
anonymous · <code>{{.ContactEmail}}</code>
|
anonymous · reply address on file
|
||||||
{{else}}
|
{{else}}
|
||||||
anonymous
|
anonymous
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -168,17 +143,10 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
|
|||||||
{{if .ResolvedAt}}
|
{{if .ResolvedAt}}
|
||||||
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
|
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
|
||||||
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
|
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
|
||||||
|
{{else if .AcknowledgedAt}}
|
||||||
|
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if .AcknowledgedAt}}
|
<span class="pending-badge">Pending</span>
|
||||||
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
|
|
||||||
{{else}}
|
|
||||||
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
|
|
||||||
<button type="submit" class="ack-btn">Acknowledge</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
|
|
||||||
<button type="submit" class="resolve-btn">Mark resolved</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executes the public /stats template against representative rows and asserts
|
||||||
|
// no submitter email can appear in the output.
|
||||||
|
func TestStatsTemplateHidesEmails(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
secret := "leak-me@example.com"
|
||||||
|
handle, name := "gopal", "Gopal Das"
|
||||||
|
rows := []model.Feedback{
|
||||||
|
// anonymous WITH a reply address on file
|
||||||
|
{ID: "1", Category: "bug", Message: "anon with email", CreatedAt: now,
|
||||||
|
HasContactEmail: true, ContactEmail: &secret},
|
||||||
|
// anonymous with no address at all
|
||||||
|
{ID: "2", Category: "general", Message: "anon no email", CreatedAt: now},
|
||||||
|
// authed submitter
|
||||||
|
{ID: "3", Category: "feature", Message: "authed", CreatedAt: now,
|
||||||
|
AuthorHandle: &handle, AuthorName: &name, AcknowledgedAt: &now, ResolvedAt: &now},
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
err := statsTpl.Execute(&sb, map[string]any{
|
||||||
|
"Stats": &repository.StatsSnapshot{Users: 1},
|
||||||
|
"Feedback": rows,
|
||||||
|
"GeneratedAt": "now",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("template execute failed: %v", err)
|
||||||
|
}
|
||||||
|
out := sb.String()
|
||||||
|
if strings.Contains(out, secret) || strings.Contains(out, "@example.com") {
|
||||||
|
t.Errorf("submitter email leaked into public /stats output")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"reply address on file", "@gopal", "anon no email"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("expected %q in output", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
@@ -70,6 +71,29 @@ func (h *TranscriptionHandler) GetByType(w http.ResponseWriter, r *http.Request)
|
|||||||
response.JSON(w, http.StatusOK, docs)
|
response.JSON(w, http.StatusOK, docs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TranscriptionHandler) GetBooks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docs, err := h.repo.GetBooks(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "failed to fetch books")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, docs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TranscriptionHandler) GetByBook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := strings.ToUpper(chi.URLParam(r, "book"))
|
||||||
|
if h.repo.BookTitle(code) == "" {
|
||||||
|
response.Error(w, http.StatusNotFound, "unknown book")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
docs, err := h.repo.GetByBook(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, docs)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *TranscriptionHandler) GetYears(w http.ResponseWriter, r *http.Request) {
|
func (h *TranscriptionHandler) GetYears(w http.ResponseWriter, r *http.Request) {
|
||||||
docs, err := h.repo.GetYears(r.Context())
|
docs, err := h.repo.GetYears(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type updateProfileReq struct {
|
|||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
Handle *string `json:"handle"`
|
Handle *string `json:"handle"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
Country *string `json:"country"`
|
Country *string `json:"country"`
|
||||||
// Devotional profile (user-editable).
|
// Devotional profile (user-editable).
|
||||||
Sampradaya *string `json:"sampradaya"`
|
Sampradaya *string `json:"sampradaya"`
|
||||||
@@ -57,6 +58,7 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
AvatarURL: req.AvatarURL,
|
AvatarURL: req.AvatarURL,
|
||||||
Handle: req.Handle,
|
Handle: req.Handle,
|
||||||
|
Bio: req.Bio,
|
||||||
Country: req.Country,
|
Country: req.Country,
|
||||||
Sampradaya: req.Sampradaya,
|
Sampradaya: req.Sampradaya,
|
||||||
Gender: req.Gender,
|
Gender: req.Gender,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VaniHandler serves the public "Vāṇī of the Day" pick for the home page.
|
||||||
|
type VaniHandler struct {
|
||||||
|
svc *service.VaniService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVaniHandler(svc *service.VaniService) *VaniHandler {
|
||||||
|
return &VaniHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
||||||
|
// the current UTC day. Responds with `data: null` if there's no content.
|
||||||
|
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := h.svc.Today(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load vāṇī of the day")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
@@ -100,6 +100,13 @@ func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Han
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithUserID returns a context carrying userID, as RequireAuth would set it.
|
||||||
|
// The exported counterpart to UserID: it lets handlers be exercised in tests
|
||||||
|
// without standing up a JWT issuer and a user repository.
|
||||||
|
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||||
|
return context.WithValue(ctx, userIDKey, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// UserID returns the authenticated user's id from the request context.
|
// UserID returns the authenticated user's id from the request context.
|
||||||
// Returns "" if RequireAuth has not run.
|
// Returns "" if RequireAuth has not run.
|
||||||
func UserID(ctx context.Context) string {
|
func UserID(ctx context.Context) string {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RealIP rewrites r.RemoteAddr to the genuine client IP for a deployment where
|
||||||
|
// a SINGLE trusted reverse proxy is the edge and nothing sits in front of it
|
||||||
|
// (here: Dokploy's Traefik — DNS points straight at it).
|
||||||
|
//
|
||||||
|
// Such an edge APPENDS the TCP peer it actually saw to the right of
|
||||||
|
// X-Forwarded-For, so the LAST entry is the real client and can't be spoofed:
|
||||||
|
// a client may only prepend left-hand entries, which we ignore. This is the
|
||||||
|
// opposite of chi's RealIP, which trusts the left-most X-Forwarded-For entry
|
||||||
|
// (and X-Real-IP) and is therefore spoofable behind an appending proxy —
|
||||||
|
// yielding the wrong GeoIP country and a forgeable rate-limit key.
|
||||||
|
//
|
||||||
|
// Downstream consumers (geoip.ClientIP, the rate limiter, the request logger)
|
||||||
|
// all read r.RemoteAddr, so fixing it here fixes them all. When no forwarded
|
||||||
|
// header is present (e.g. a direct hit on the container), RemoteAddr is left
|
||||||
|
// untouched.
|
||||||
|
func RealIP(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ip := clientIPFromEdge(r); ip != "" {
|
||||||
|
r.RemoteAddr = ip
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPFromEdge(r *http.Request) string {
|
||||||
|
// Right-most X-Forwarded-For entry = the IP the trusted edge observed.
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
last := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if net.ParseIP(last) != nil {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: the edge also overwrites X-Real-IP from the connection.
|
||||||
|
if xr := strings.TrimSpace(r.Header.Get("X-Real-Ip")); net.ParseIP(xr) != nil {
|
||||||
|
return xr
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
+46
-4
@@ -17,7 +17,41 @@ type AdminUser struct {
|
|||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
BannedAt *time.Time `json:"banned_at"`
|
BannedAt *time.Time `json:"banned_at"`
|
||||||
|
PostCount int `json:"post_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Connected auth methods. HasPassword means email+password login is set up;
|
||||||
|
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
Providers []string `json:"providers"`
|
||||||
|
|
||||||
|
// Effective save limits in force for this user (plan default merged with any
|
||||||
|
// admin override); -1 means unlimited. The admin UI pre-fills the "Edit
|
||||||
|
// limits" modal with these.
|
||||||
|
BookmarkLimit int `json:"bookmark_limit"`
|
||||||
|
PublicCollectionLimit int `json:"public_collection_limit"`
|
||||||
|
PrivateCollectionLimit int `json:"private_collection_limit"`
|
||||||
|
|
||||||
|
// Direct-disciple link. DiscipleID is the raw Mongo ObjectID stored on the
|
||||||
|
// users row; DiscipleName is hydrated from Mongo by the admin service so the
|
||||||
|
// panel can show who the account is linked to (nil when unlinked, or when
|
||||||
|
// the id no longer resolves to a disciple document).
|
||||||
|
DiscipleID *string `json:"disciple_id"`
|
||||||
|
DiscipleName *string `json:"disciple_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDisciple is one option in the admin panel's disciple picker — enough to
|
||||||
|
// disambiguate similarly-named disciples, plus the handle of the account that
|
||||||
|
// already claims it (nil when free to link).
|
||||||
|
type AdminDisciple struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InitiatedName string `json:"initiated_name"`
|
||||||
|
Suffix string `json:"suffix"`
|
||||||
|
InitiationDate *time.Time `json:"initiation_date"`
|
||||||
|
InitiationPlace *string `json:"initiation_place"`
|
||||||
|
// LinkedHandle is the @handle of the user already linked to this disciple,
|
||||||
|
// so the picker can grey the row out instead of failing on submit.
|
||||||
|
LinkedHandle *string `json:"linked_handle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminPost is a row in the admin posts table.
|
// AdminPost is a row in the admin posts table.
|
||||||
@@ -54,10 +88,11 @@ type AdminFeedback struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ContactEmail *string `json:"contact_email"`
|
ContactEmail *string `json:"contact_email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ResolvedAt *time.Time `json:"resolved_at"`
|
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||||
RepliedAt *time.Time `json:"replied_at"`
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
RepliedAt *time.Time `json:"replied_at"`
|
||||||
ReplyMessage *string `json:"reply_message"`
|
ReplyMessage *string `json:"reply_message"`
|
||||||
AuthorHandle *string `json:"author_handle"`
|
AuthorHandle *string `json:"author_handle"`
|
||||||
AuthorName *string `json:"author_name"`
|
AuthorName *string `json:"author_name"`
|
||||||
@@ -77,6 +112,13 @@ type AdminReport struct {
|
|||||||
ReporterHandle *string `json:"reporter_handle"`
|
ReporterHandle *string `json:"reporter_handle"`
|
||||||
ReporterName *string `json:"reporter_name"`
|
ReporterName *string `json:"reporter_name"`
|
||||||
|
|
||||||
|
// ResolutionNote is the admin's note left when resolving (nil while open).
|
||||||
|
ResolutionNote *string `json:"resolution_note"`
|
||||||
|
// TargetRemoved is true when the reported content is currently removed —
|
||||||
|
// a soft-deleted post/comment, or a banned account — so the UI can offer
|
||||||
|
// "Undo" instead of "Delete"/"Ban".
|
||||||
|
TargetRemoved bool `json:"target_removed"`
|
||||||
|
|
||||||
// Target of the report. TargetID is the post/comment/user id; TargetText
|
// 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
|
// is the post caption, comment body, or reported user's handle so the
|
||||||
// admin sees what was flagged without a second fetch. TargetAuthorHandle
|
// admin sees what was flagged without a second fetch. TargetAuthorHandle
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AudioProgress is one persisted resume point for the floating audio
|
||||||
|
// player — the last-known playback position of a single audio file for a
|
||||||
|
// user. Key is the track URL with query/hash stripped (the stable file
|
||||||
|
// identity, owned by the frontend). Position/Duration mirror the
|
||||||
|
// HTMLAudioElement's currentTime/duration in seconds. UpdatedAt is set by
|
||||||
|
// the client so multi-device writes resolve newest-wins on upsert.
|
||||||
|
type AudioProgress struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
Duration *float64 `json:"duration,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Who may start a conversation with a user.
|
||||||
|
const (
|
||||||
|
WhoAnyone = "anyone"
|
||||||
|
WhoFriends = "friends"
|
||||||
|
WhoAccountAge = "account_age"
|
||||||
|
WhoSameGender = "same_gender"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatSettings are a user's own chat preferences. Absence of a stored row means
|
||||||
|
// all defaults, so DefaultChatSettings and the column defaults must agree.
|
||||||
|
type ChatSettings struct {
|
||||||
|
ReadReceipts bool `json:"read_receipts"`
|
||||||
|
WhoCanMessage string `json:"who_can_message"`
|
||||||
|
MinAccountAgeMonths int `json:"min_account_age_months"`
|
||||||
|
TrainKautilya bool `json:"train_kautilya"`
|
||||||
|
// GenderSet reports whether the user has a gender on their profile. The
|
||||||
|
// same_gender option is unusable without one, so the UI needs to know.
|
||||||
|
GenderSet bool `json:"gender_set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultChatSettings mirrors the column defaults in migration 0050.
|
||||||
|
func DefaultChatSettings() ChatSettings {
|
||||||
|
return ChatSettings{
|
||||||
|
ReadReceipts: true,
|
||||||
|
WhoCanMessage: WhoAnyone,
|
||||||
|
MinAccountAgeMonths: 3,
|
||||||
|
TrainKautilya: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation kinds. DMs and groups fill the inbox's "Chats" tab; ai fills
|
||||||
|
// the "Kautilya" tab.
|
||||||
|
// Group send policies. Only groups honour these — a DM has no administrators
|
||||||
|
// and the Kautilya thread has one participant.
|
||||||
|
const (
|
||||||
|
SendPolicyEveryone = "everyone"
|
||||||
|
SendPolicyAdmins = "admins"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Participant roles, most privileged first.
|
||||||
|
const (
|
||||||
|
RoleOwner = "owner"
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleMember = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConversationDM = "dm"
|
||||||
|
ConversationGroup = "group"
|
||||||
|
ConversationAI = "ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message kinds describe who produced a message, not what it contains — the
|
||||||
|
// content type lives on its attachments.
|
||||||
|
const (
|
||||||
|
MessageText = "text"
|
||||||
|
MessageSystem = "system"
|
||||||
|
MessageAssistant = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversation is a chat thread.
|
||||||
|
type Conversation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
AvatarKey *string `json:"avatar_key,omitempty"`
|
||||||
|
// SendPolicy is "everyone" or "admins"; meaningful for groups only.
|
||||||
|
SendPolicy string `json:"send_policy"`
|
||||||
|
LastSeq int64 `json:"last_seq"`
|
||||||
|
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPeer is the counterpart shown on a DM row: enough to render the inbox
|
||||||
|
// without a second round trip.
|
||||||
|
type ChatPeer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||||
|
// Role and JoinedAt are filled for a thread's participant list; they are
|
||||||
|
// empty on the `peer` of a DM, where a role means nothing.
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagePreview is the truncated last-message line on an inbox row.
|
||||||
|
type MessagePreview struct {
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
SenderID *string `json:"sender_id,omitempty"`
|
||||||
|
SenderName string `json:"sender_name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxItem is one row of the chat inbox.
|
||||||
|
type InboxItem struct {
|
||||||
|
Conversation
|
||||||
|
Unread int64 `json:"unread"`
|
||||||
|
// MutedUntil is non-nil while the caller has this thread muted. Unread
|
||||||
|
// still accrues — muting silences the alert, it does not hide the chat.
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"`
|
||||||
|
Peer *ChatPeer `json:"peer,omitempty"` // DMs only
|
||||||
|
LastMessage *MessagePreview `json:"last_message,omitempty"` // nil on an empty thread
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatRelationship is how two people in a DM stand with each other — what the
|
||||||
|
// chat profile shows above the shared-media tabs.
|
||||||
|
type ChatRelationship struct {
|
||||||
|
// Following: the caller follows the peer. FollowedBy: the reverse. Both
|
||||||
|
// true is mutual; one true is a one-way follow.
|
||||||
|
Following bool `json:"following"`
|
||||||
|
FollowedBy bool `json:"followed_by"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
BlockedBy bool `json:"blocked_by"`
|
||||||
|
// FriendsSince is when the first follow between them was accepted — the
|
||||||
|
// "friends since" line. Nil when neither follows the other.
|
||||||
|
FriendsSince *time.Time `json:"friends_since,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationDetail is everything a thread screen needs in one request:
|
||||||
|
// the thread, who is in it, and the caller's own state. It replaces scanning
|
||||||
|
// the inbox to find a single conversation.
|
||||||
|
type ConversationDetail struct {
|
||||||
|
Conversation
|
||||||
|
// Peer is the counterpart on a DM, nil otherwise.
|
||||||
|
Peer *ChatPeer `json:"peer,omitempty"`
|
||||||
|
// Participants is everyone still in the thread, the caller included, so
|
||||||
|
// the UI can name a message's sender without another lookup.
|
||||||
|
Participants []ChatPeer `json:"participants"`
|
||||||
|
Unread int64 `json:"unread"`
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"`
|
||||||
|
// MyRole is the caller's role in this thread, so the UI can show admin
|
||||||
|
// controls without deriving it from the participant list.
|
||||||
|
MyRole string `json:"my_role"`
|
||||||
|
// Relationship is populated for DMs only; a group has no single peer to
|
||||||
|
// stand in a relationship with.
|
||||||
|
Relationship *ChatRelationship `json:"relationship,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant is a user's membership of a conversation, including the two
|
||||||
|
// cursors that drive tick state.
|
||||||
|
type Participant struct {
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
LastReadSeq int64 `json:"last_read_seq"`
|
||||||
|
LastDeliveredSeq int64 `json:"last_delivered_seq"`
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"`
|
||||||
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
|
LeftAt *time.Time `json:"left_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment kinds.
|
||||||
|
const (
|
||||||
|
AttachmentImage = "image"
|
||||||
|
AttachmentVideo = "video"
|
||||||
|
AttachmentAudio = "audio"
|
||||||
|
AttachmentFile = "file"
|
||||||
|
AttachmentLink = "link"
|
||||||
|
AttachmentEntity = "entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attachment is a media file, link preview or shared entity on a message.
|
||||||
|
//
|
||||||
|
// Media carries an R2 key rather than a URL: chat media lives in the private
|
||||||
|
// bucket and is only ever reachable through a participant-checked, short-lived
|
||||||
|
// presigned GET, so there is no URL to store.
|
||||||
|
type Attachment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Ord int `json:"ord"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
|
||||||
|
// Media
|
||||||
|
Mime *string `json:"mime,omitempty"`
|
||||||
|
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||||
|
Width *int `json:"width,omitempty"`
|
||||||
|
Height *int `json:"height,omitempty"`
|
||||||
|
DurationMs *int `json:"duration_ms,omitempty"`
|
||||||
|
Blurhash *string `json:"blurhash,omitempty"`
|
||||||
|
FileName *string `json:"file_name,omitempty"`
|
||||||
|
|
||||||
|
// Link preview. Metadata is fetched by the BFF, never supplied by a
|
||||||
|
// client — otherwise any link could be made to claim anything.
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
SiteName *string `json:"site_name,omitempty"`
|
||||||
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyPreview is the quoted snippet shown above a reply. Deliberately small:
|
||||||
|
// the bubble needs a name and a line of text, not the whole message.
|
||||||
|
type ReplyPreview struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
SenderID *string `json:"sender_id,omitempty"`
|
||||||
|
SenderName string `json:"sender_name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is one chat message. Entities carries the formatting spans produced
|
||||||
|
// by ParseEntities; it is always a JSON array, never null.
|
||||||
|
type Message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
SenderID *string `json:"sender_id,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Entities json.RawMessage `json:"entities"`
|
||||||
|
ClientMsgID *string `json:"client_msg_id,omitempty"`
|
||||||
|
ReplyToID *string `json:"reply_to_id,omitempty"`
|
||||||
|
ForwardedFromID *string `json:"forwarded_from_id,omitempty"`
|
||||||
|
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// ReplyTo is hydrated on read when this message replies to another in the
|
||||||
|
// SAME conversation. Nil otherwise.
|
||||||
|
ReplyTo *ReplyPreview `json:"reply_to,omitempty"`
|
||||||
|
// Attachments is always present (possibly empty) so clients need no nil
|
||||||
|
// check before iterating.
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipts are the conversation-wide delivery cursors used to draw ticks on
|
||||||
|
// the sender's own messages. Both are the MINIMUM across the other
|
||||||
|
// participants, so in a group a message is "read" only once everyone has read
|
||||||
|
// it — matching what users expect from the blue double tick.
|
||||||
|
type Receipts struct {
|
||||||
|
DeliveredSeq int64 `json:"delivered_seq"`
|
||||||
|
ReadSeq int64 `json:"read_seq"`
|
||||||
|
}
|
||||||
@@ -31,6 +31,21 @@ type Feedback struct {
|
|||||||
// resolve is a no-op.
|
// resolve is a no-op.
|
||||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
|
||||||
|
// ReplyMessage is the admin's written reply to the submitter, sent
|
||||||
|
// (and stored) from the admin panel. NULL until an admin replies.
|
||||||
|
// RepliedAt stamps when it was sent. Surfaced to the submitter on the
|
||||||
|
// "My feedback" page as the team's response.
|
||||||
|
ReplyMessage *string `json:"reply_message,omitempty"`
|
||||||
|
RepliedAt *time.Time `json:"replied_at,omitempty"`
|
||||||
|
|
||||||
|
// HasContactEmail reports *whether* an anonymous submitter left a reply-to
|
||||||
|
// address, without carrying the address itself. Populated only by
|
||||||
|
// Recent(), whose sole consumer is the PUBLIC /stats page — that page has
|
||||||
|
// to distinguish "anonymous, but we can reply" from "anonymous dead end"
|
||||||
|
// while never handling the email. Admins see the real address in the
|
||||||
|
// admin panel, which is auth-gated.
|
||||||
|
HasContactEmail bool `json:"-"`
|
||||||
|
|
||||||
AuthorHandle *string `json:"author_handle,omitempty"`
|
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||||
AuthorName *string `json:"author_name,omitempty"`
|
AuthorName *string `json:"author_name,omitempty"`
|
||||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// Plan-default save limits (-1 = unlimited). Per-user override columns on the
|
||||||
|
// users row take precedence when non-nil; admins set them from the panel.
|
||||||
|
const (
|
||||||
|
planPremium = "premium"
|
||||||
|
|
||||||
|
defaultFreeBookmarkLimit = 1
|
||||||
|
defaultPremiumBookmarkLimit = 5
|
||||||
|
defaultFreeCollectionLimit = 5 // per visibility; premium = unlimited
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserLimits are the effective per-resource caps in force for a user (-1 means
|
||||||
|
// unlimited). Surfaced on the self user payload so the app can render
|
||||||
|
// "X / Y used" and gate create actions without hardcoding the numbers.
|
||||||
|
type UserLimits struct {
|
||||||
|
Bookmarks int `json:"bookmarks"`
|
||||||
|
PublicCollections int `json:"public_collections"`
|
||||||
|
PrivateCollections int `json:"private_collections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLimits computes the effective caps from a plan plus optional per-user
|
||||||
|
// overrides (nil = use the plan default). Shared by the self payload and the
|
||||||
|
// admin user listing so both agree on what's in force.
|
||||||
|
func ResolveLimits(plan string, bookmark, publicCollection, privateCollection *int) UserLimits {
|
||||||
|
premium := plan == planPremium
|
||||||
|
|
||||||
|
bm := defaultFreeBookmarkLimit
|
||||||
|
if premium {
|
||||||
|
bm = defaultPremiumBookmarkLimit
|
||||||
|
}
|
||||||
|
pub, priv := defaultFreeCollectionLimit, defaultFreeCollectionLimit
|
||||||
|
if premium {
|
||||||
|
pub, priv = -1, -1
|
||||||
|
}
|
||||||
|
if bookmark != nil {
|
||||||
|
bm = *bookmark
|
||||||
|
}
|
||||||
|
if publicCollection != nil {
|
||||||
|
pub = *publicCollection
|
||||||
|
}
|
||||||
|
if privateCollection != nil {
|
||||||
|
priv = *privateCollection
|
||||||
|
}
|
||||||
|
return UserLimits{Bookmarks: bm, PublicCollections: pub, PrivateCollections: priv}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveLimits resolves this user's caps (plan default + any overrides).
|
||||||
|
func (u *User) EffectiveLimits() UserLimits {
|
||||||
|
return ResolveLimits(u.Plan, u.BookmarkLimit, u.PublicCollectionLimit, u.PrivateCollectionLimit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AdminShortURL is a row in the admin short-URL table (a projection of
|
||||||
|
// shortened_urls served only to admins).
|
||||||
|
type AdminShortURL struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
LongURL string `json:"long_url"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ClickCount int64 `json:"click_count"`
|
||||||
|
HumanClickCount int64 `json:"human_click_count"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortURLRangeStat is the (created, clicks) pair for one time window.
|
||||||
|
type ShortURLRangeStat struct {
|
||||||
|
Created int64 `json:"created"` // short URLs created in the window
|
||||||
|
Clicks int64 `json:"clicks"` // clicks recorded in the window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortURLStats is the admin dashboard's short-URL summary: fixed today/week/
|
||||||
|
// month windows, all-time totals, and an optional custom range.
|
||||||
|
type ShortURLStats struct {
|
||||||
|
Today ShortURLRangeStat `json:"today"`
|
||||||
|
Week ShortURLRangeStat `json:"week"`
|
||||||
|
Month ShortURLRangeStat `json:"month"`
|
||||||
|
TotalURLs int64 `json:"total_urls"`
|
||||||
|
TotalClicks int64 `json:"total_clicks"`
|
||||||
|
Range *ShortURLRangeStat `json:"range,omitempty"` // present only when from/to given
|
||||||
|
}
|
||||||
@@ -34,10 +34,10 @@ type Post struct {
|
|||||||
// is a reshare; the four `Original*` fields carry the canonical
|
// is a reshare; the four `Original*` fields carry the canonical
|
||||||
// post's author info so the frontend can render a
|
// post's author info so the frontend can render a
|
||||||
// "Reshared from @handle" link without an extra round-trip.
|
// "Reshared from @handle" link without an extra round-trip.
|
||||||
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
||||||
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
||||||
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
||||||
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
||||||
|
|
||||||
// ViewerHasReshared is true when the calling user has reshared the
|
// ViewerHasReshared is true when the calling user has reshared the
|
||||||
// canonical version of this post. Hydrated by the service layer
|
// canonical version of this post. Hydrated by the service layer
|
||||||
@@ -71,6 +71,10 @@ type Post struct {
|
|||||||
// author follows the caller. Both false for anonymous viewers / own posts.
|
// author follows the caller. Both false for anonymous viewers / own posts.
|
||||||
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
|
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
|
||||||
AuthorFollowsViewer bool `json:"author_follows_viewer"`
|
AuthorFollowsViewer bool `json:"author_follows_viewer"`
|
||||||
|
|
||||||
|
// ViewerFollowRequested: the caller has a pending (unaccepted) follow
|
||||||
|
// request to this post's author. Drives the "Requested" feed affordance.
|
||||||
|
ViewerFollowRequested bool `json:"viewer_follow_requested"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment is one comment (or reply) on a post. Threading is one level deep:
|
// Comment is one comment (or reply) on a post. Threading is one level deep:
|
||||||
@@ -142,12 +146,17 @@ type CollectionRecord struct {
|
|||||||
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
||||||
// expose to anyone, plus aggregate counts and the user's posts.
|
// expose to anyone, plus aggregate counts and the user's posts.
|
||||||
type PublicProfile struct {
|
type PublicProfile struct {
|
||||||
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"`
|
||||||
|
// Bio is plain text; `@handle` mentions are linkified at render time.
|
||||||
|
Bio *string `json:"bio"`
|
||||||
JoinedAt time.Time `json:"joined_at"`
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
PostCount int `json:"post_count"`
|
PostCount int `json:"post_count"`
|
||||||
BookmarkCount int `json:"-"` // never serialized — private
|
BookmarkCount int `json:"-"` // never serialized — private
|
||||||
|
// IsDirectDisciple marks an account an admin has linked to the disciples
|
||||||
|
// directory — one of Śrīla Prabhupāda's own initiated disciples.
|
||||||
|
IsDirectDisciple bool `json:"is_direct_disciple"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelationshipState is the viewer's relationship to a target profile,
|
// RelationshipState is the viewer's relationship to a target profile,
|
||||||
@@ -174,6 +183,15 @@ type RelationshipState struct {
|
|||||||
RequestsYou bool `json:"requests_you"`
|
RequestsYou bool `json:"requests_you"`
|
||||||
Blocked bool `json:"blocked"`
|
Blocked bool `json:"blocked"`
|
||||||
BlockedBy bool `json:"blocked_by"`
|
BlockedBy bool `json:"blocked_by"`
|
||||||
|
// CanMessage reports whether the viewer may START a conversation with this
|
||||||
|
// profile, given the target's "who can message you" setting and any block.
|
||||||
|
// It lets the UI decide whether to offer a Message button rather than
|
||||||
|
// offering one that fails on tap.
|
||||||
|
CanMessage bool `json:"can_message"`
|
||||||
|
// MessageRestriction names the target's rule when CanMessage is false, so
|
||||||
|
// the UI can explain instead of silently hiding the button. Empty when
|
||||||
|
// messaging is allowed, or "blocked" when a block is the reason.
|
||||||
|
MessageRestriction string `json:"message_restriction,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow is one row of the follows table — a directed, request-based
|
// Follow is one row of the follows table — a directed, request-based
|
||||||
|
|||||||
+34
-4
@@ -42,6 +42,23 @@ type User struct {
|
|||||||
// nil until known.
|
// nil until known.
|
||||||
Country *string `json:"country"`
|
Country *string `json:"country"`
|
||||||
|
|
||||||
|
// DiscipleID links this account to a row in the Mongo `disciples`
|
||||||
|
// collection (ObjectID hex) when the account belongs to one of Śrīla
|
||||||
|
// Prabhupāda's own initiated disciples. Admin-set only — never
|
||||||
|
// self-serve — and unique across users, so one disciple maps to at most
|
||||||
|
// one account. nil for everybody else.
|
||||||
|
//
|
||||||
|
// Tagged `json:"-"`: the raw cross-store id is an internal detail. The
|
||||||
|
// public profile exposes only the derived `is_direct_disciple` flag.
|
||||||
|
DiscipleID *string `json:"-"`
|
||||||
|
|
||||||
|
// Bio is a short self-introduction (max 200 characters, enforced in the
|
||||||
|
// service by rune count) shown on the profile header. Stored as PLAIN
|
||||||
|
// TEXT — `@handle` mentions are kept verbatim and linkified by the
|
||||||
|
// frontend at render time, so it must never be injected as HTML.
|
||||||
|
// nil when unset.
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
|
||||||
// --- Devotional profile (user-editable) ---
|
// --- Devotional profile (user-editable) ---
|
||||||
// Gender is a free-but-constrained string ("male"|"female"|"other"),
|
// Gender is a free-but-constrained string ("male"|"female"|"other"),
|
||||||
// nil when unspecified.
|
// nil when unspecified.
|
||||||
@@ -75,19 +92,32 @@ type User struct {
|
|||||||
// BannedAt suspends the account when set: RequireAuth rejects the session
|
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||||
// and LogIn refuses to issue a new one. nil = in good standing.
|
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
|
|
||||||
|
// Per-user save-limit overrides (admin-settable; nil = use the plan
|
||||||
|
// default). Never serialized raw — surfaced only via the derived `limits`
|
||||||
|
// object in MarshalJSON so the app can render/enforce the right caps.
|
||||||
|
BookmarkLimit *int `json:"-"`
|
||||||
|
PublicCollectionLimit *int `json:"-"`
|
||||||
|
PrivateCollectionLimit *int `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||||
// "Change password" vs "Set a password" for OAuth-only accounts, without
|
// "Change password" vs "Set a password" for OAuth-only accounts, without
|
||||||
// the password hash itself ever leaving the server.
|
// the password hash itself ever leaving the server. `is_direct_disciple` is
|
||||||
|
// likewise derived — the raw cross-store DiscipleID stays server-side while
|
||||||
|
// the frontend gets the one bit it needs to render the badge.
|
||||||
func (u User) MarshalJSON() ([]byte, error) {
|
func (u User) MarshalJSON() ([]byte, error) {
|
||||||
type alias User
|
type alias User
|
||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
alias
|
alias
|
||||||
HasPassword bool `json:"has_password"`
|
HasPassword bool `json:"has_password"`
|
||||||
|
IsDirectDisciple bool `json:"is_direct_disciple"`
|
||||||
|
Limits UserLimits `json:"limits"`
|
||||||
}{
|
}{
|
||||||
alias: alias(u),
|
alias: alias(u),
|
||||||
HasPassword: u.PasswordHash != nil,
|
HasPassword: u.PasswordHash != nil,
|
||||||
|
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
|
||||||
|
Limits: u.EffectiveLimits(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// VaniPayload is the prebuilt display blob for the "Vāṇī of the Day" CTA. It's
|
||||||
|
// owned by the frontend's rendering needs and stored verbatim as JSONB, so the
|
||||||
|
// home page can render without re-reading Mongo.
|
||||||
|
type VaniPayload struct {
|
||||||
|
Kind string `json:"kind"` // "verse" | "prose" | "transcript"
|
||||||
|
Label string `json:"label"` // "Verse" | "Chapter" | "Lecture" | "Conversation"
|
||||||
|
Title string `json:"title"` // reference / chapter title / lecture name
|
||||||
|
Subtitle string `json:"subtitle"` // short excerpt or date · place
|
||||||
|
Href string `json:"href"` // in-app path to open the item
|
||||||
|
BookTitle string `json:"book_title,omitempty"` // e.g. "Bhagavad-gītā As It Is"
|
||||||
|
Date string `json:"date,omitempty"` // transcript date, for the "Lecture • DATE" line
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaniPick is one persisted day's choice. RefID is `<kind>:<mongo _id hex>` and
|
||||||
|
// is what the 90-day no-repeat rule dedupes on.
|
||||||
|
type VaniPick struct {
|
||||||
|
Day time.Time
|
||||||
|
Kind string
|
||||||
|
RefID string
|
||||||
|
Payload VaniPayload
|
||||||
|
}
|
||||||
@@ -33,12 +33,20 @@ func clampLimit(limit int) int {
|
|||||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
limit = clampLimit(limit)
|
limit = clampLimit(limit)
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at,
|
SELECT u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan,
|
||||||
|
u.email_verified_at, u.banned_at,
|
||||||
|
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count,
|
||||||
|
u.created_at,
|
||||||
|
(u.password_hash IS NOT NULL) AS has_password,
|
||||||
|
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
|
||||||
|
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||||
|
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||||
|
u.disciple_id,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM users
|
FROM users u
|
||||||
WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%'
|
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||||
OR COALESCE(email, '') ILIKE '%'||$1||'%')
|
OR COALESCE(u.email, '') ILIKE '%'||$1||'%')
|
||||||
ORDER BY created_at DESC
|
ORDER BY u.created_at DESC
|
||||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
||||||
@@ -49,15 +57,109 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
|||||||
total := 0
|
total := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u model.AdminUser
|
var u model.AdminUser
|
||||||
|
var bookmarkLimit, publicCollLimit, privateCollLimit *int
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
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 {
|
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||||
|
&u.HasPassword, &u.Providers,
|
||||||
|
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
||||||
|
&u.DiscipleID, &total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||||
}
|
}
|
||||||
|
// Resolve plan default + overrides so the panel shows what's in force.
|
||||||
|
lim := model.ResolveLimits(u.Plan, bookmarkLimit, publicCollLimit, privateCollLimit)
|
||||||
|
u.BookmarkLimit = lim.Bookmarks
|
||||||
|
u.PublicCollectionLimit = lim.PublicCollections
|
||||||
|
u.PrivateCollectionLimit = lim.PrivateCollections
|
||||||
out = append(out, u)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
return out, total, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDiscipleClaimed is returned by LinkDisciple when another account already
|
||||||
|
// holds the requested disciple — the partial unique index on disciple_id makes
|
||||||
|
// the one-to-one rule a database guarantee rather than a check-then-write race.
|
||||||
|
var ErrDiscipleClaimed = errors.New("disciple already linked to another user")
|
||||||
|
|
||||||
|
// LinkDisciple points a user at a disciple record in Mongo (ObjectID hex).
|
||||||
|
// Re-linking simply overwrites any previous value, so "change the link" needs
|
||||||
|
// no separate unlink step. Returns ErrDiscipleClaimed when a different account
|
||||||
|
// already claims that disciple.
|
||||||
|
//
|
||||||
|
// `previous` is the link the row held *before* this call (nil when it had
|
||||||
|
// none), which is what lets the caller send the welcome email only on the
|
||||||
|
// first link — correcting a mis-link shouldn't welcome the devotee twice. The
|
||||||
|
// FROM subquery snapshots the old value in the same statement, so there's no
|
||||||
|
// read-then-write race.
|
||||||
|
func (r *AdminRepo) LinkDisciple(ctx context.Context, userID, discipleID string) (found bool, previous *string, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE users u
|
||||||
|
SET disciple_id = $2,
|
||||||
|
updated_at = now()
|
||||||
|
FROM (SELECT id, disciple_id AS old FROM users WHERE id = $1) prev
|
||||||
|
WHERE u.id = prev.id
|
||||||
|
RETURNING prev.old`, userID, discipleID).Scan(&previous)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if IsUniqueViolation(err) {
|
||||||
|
return false, nil, ErrDiscipleClaimed
|
||||||
|
}
|
||||||
|
return false, nil, fmt.Errorf("admin link disciple: %w", err)
|
||||||
|
}
|
||||||
|
return true, previous, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimDiscipleAnnouncement records that this account's arrival has been
|
||||||
|
// announced to the community, returning true only for the caller that actually
|
||||||
|
// inserted the row.
|
||||||
|
//
|
||||||
|
// This is the whole idempotency guard for the broadcast: announcing writes a
|
||||||
|
// notification to every user, so it must fire at most once per devotee. Doing
|
||||||
|
// it as INSERT … ON CONFLICT DO NOTHING makes the check atomic — a
|
||||||
|
// double-clicked button, a retried request, or two admins acting at once still
|
||||||
|
// yields exactly one announcement.
|
||||||
|
func (r *AdminRepo) ClaimDiscipleAnnouncement(ctx context.Context, userID, discipleID string) (bool, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO disciple_announcements (user_id, disciple_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING`, userID, discipleID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("claim disciple announcement: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkDisciple clears a user's disciple link. Idempotent: returns true as
|
||||||
|
// long as the user exists, whether or not a link was present.
|
||||||
|
func (r *AdminRepo) UnlinkDisciple(ctx context.Context, userID string) (bool, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET disciple_id = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin unlink disciple: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserLimits writes the per-user save-limit overrides (nil clears one back
|
||||||
|
// to the plan default). Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET bookmark_limit = $2,
|
||||||
|
public_collection_limit = $3,
|
||||||
|
private_collection_limit = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`, userID, bookmark, publicCollection, privateCollection)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set user limits: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UserExists reports whether a user id is real (used before sending a
|
// UserExists reports whether a user id is real (used before sending a
|
||||||
// notification to it).
|
// notification to it).
|
||||||
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
||||||
@@ -146,7 +248,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
|||||||
}
|
}
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
||||||
f.resolved_at, f.replied_at, f.reply_message,
|
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
|
||||||
u.handle, u.name, u.email,
|
u.handle, u.name, u.email,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM feedback f
|
FROM feedback f
|
||||||
@@ -166,7 +268,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f model.AdminFeedback
|
var f model.AdminFeedback
|
||||||
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
&f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||||
}
|
}
|
||||||
@@ -175,8 +277,9 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
|||||||
return out, total, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveReply stores an admin reply on a feedback row, stamps replied_at, and
|
// SaveReply stores an admin reply on a feedback row and stamps replied_at. It
|
||||||
// resolves it (if not already). It returns the recipient's email (the
|
// does NOT change resolved_at — replying and resolving are independent actions
|
||||||
|
// (resolve via ResolveFeedback). It returns the recipient's email (the
|
||||||
// submitter's account email, else the anonymous contact_email — may be nil if
|
// 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
|
// neither is known) and name so the caller can send the reply email. ok is
|
||||||
// false when the id doesn't exist.
|
// false when the id doesn't exist.
|
||||||
@@ -184,8 +287,7 @@ func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *s
|
|||||||
err = r.pg.Pool.QueryRow(ctx, `
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
UPDATE feedback f
|
UPDATE feedback f
|
||||||
SET reply_message = $2,
|
SET reply_message = $2,
|
||||||
replied_at = now(),
|
replied_at = now()
|
||||||
resolved_at = COALESCE(resolved_at, now())
|
|
||||||
WHERE f.id = $1
|
WHERE f.id = $1
|
||||||
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
||||||
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
||||||
@@ -212,6 +314,7 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit
|
|||||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
ru.handle, ru.name,
|
ru.handle, ru.name,
|
||||||
r.post_id, p.caption, pu.handle,
|
r.post_id, p.caption, pu.handle,
|
||||||
|
r.resolution_note, COALESCE(p.deleted_at IS NOT NULL, false),
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM post_reports r
|
FROM post_reports r
|
||||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
@@ -227,6 +330,7 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit
|
|||||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
ru.handle, ru.name,
|
ru.handle, ru.name,
|
||||||
r.comment_id, c.body, cu.handle,
|
r.comment_id, c.body, cu.handle,
|
||||||
|
r.resolution_note, COALESCE(c.deleted_at IS NOT NULL, false),
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM comment_reports r
|
FROM comment_reports r
|
||||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
@@ -241,8 +345,9 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit
|
|||||||
sql = `
|
sql = `
|
||||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
ru.handle, ru.name,
|
ru.handle, ru.name,
|
||||||
r.reported_id, tu.handle, tu.handle
|
r.reported_id, tu.handle, tu.handle,
|
||||||
, COUNT(*) OVER() AS total
|
r.resolution_note, COALESCE(tu.banned_at IS NOT NULL, false),
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
FROM user_reports r
|
FROM user_reports r
|
||||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
LEFT JOIN users tu ON tu.id = r.reported_id
|
LEFT JOIN users tu ON tu.id = r.reported_id
|
||||||
@@ -267,7 +372,8 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit
|
|||||||
rep := model.AdminReport{Type: reportType}
|
rep := model.AdminReport{Type: reportType}
|
||||||
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
||||||
&rep.ReporterHandle, &rep.ReporterName,
|
&rep.ReporterHandle, &rep.ReporterName,
|
||||||
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil {
|
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle,
|
||||||
|
&rep.ResolutionNote, &rep.TargetRemoved, &total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, rep)
|
out = append(out, rep)
|
||||||
@@ -275,10 +381,10 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit
|
|||||||
return out, total, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveReport stamps resolved_at=now() on a still-open report of the given
|
// ResolveReport stamps resolved_at=now() and stores the admin's note on a
|
||||||
// type. Idempotent and one-way (the WHERE guard). Returns true when this call
|
// still-open report of the given type. Idempotent and one-way (the WHERE
|
||||||
// is the one that resolved it.
|
// guard). Returns true when this call is the one that resolved it.
|
||||||
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) {
|
||||||
var table string
|
var table string
|
||||||
switch reportType {
|
switch reportType {
|
||||||
case "post":
|
case "post":
|
||||||
@@ -292,8 +398,8 @@ func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (b
|
|||||||
}
|
}
|
||||||
// table is from a fixed whitelist above — safe to interpolate.
|
// table is from a fixed whitelist above — safe to interpolate.
|
||||||
tag, err := r.pg.Pool.Exec(ctx,
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table),
|
fmt.Sprintf(`UPDATE %s SET resolved_at = now(), resolution_note = $2 WHERE id = $1 AND resolved_at IS NULL`, table),
|
||||||
id,
|
id, note,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListShortURLs returns a page of shortened URLs filtered by an optional query
|
||||||
|
// (matched against code or target URL), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, code, long_url, content_type, click_count, human_click_count,
|
||||||
|
disabled, created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM shortened_urls
|
||||||
|
WHERE ($1 = '' OR code ILIKE '%'||$1||'%' OR long_url ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list short urls: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminShortURL{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var s model.AdminShortURL
|
||||||
|
if err := rows.Scan(&s.ID, &s.Code, &s.LongURL, &s.ContentType,
|
||||||
|
&s.ClickCount, &s.HumanClickCount, &s.Disabled, &s.CreatedAt, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin short url: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortURLStats computes created + click counts for the three fixed windows
|
||||||
|
// (thresholds passed in), the all-time totals, and — when from/to is given —
|
||||||
|
// a custom range (from inclusive, to exclusive). "Created" comes from
|
||||||
|
// shortened_urls.created_at; "clicks" from the shortened_url_clicks log.
|
||||||
|
func (r *AdminRepo) ShortURLStats(
|
||||||
|
ctx context.Context,
|
||||||
|
todayStart, weekStart, monthStart time.Time,
|
||||||
|
from, to *time.Time,
|
||||||
|
) (*model.ShortURLStats, error) {
|
||||||
|
var s model.ShortURLStats
|
||||||
|
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= $1),
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= $2),
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= $3)
|
||||||
|
FROM shortened_urls`, todayStart, weekStart, monthStart).
|
||||||
|
Scan(&s.TotalURLs, &s.Today.Created, &s.Week.Created, &s.Month.Created); err != nil {
|
||||||
|
return nil, fmt.Errorf("short url created stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE clicked_at >= $1),
|
||||||
|
COUNT(*) FILTER (WHERE clicked_at >= $2),
|
||||||
|
COUNT(*) FILTER (WHERE clicked_at >= $3)
|
||||||
|
FROM shortened_url_clicks`, todayStart, weekStart, monthStart).
|
||||||
|
Scan(&s.TotalClicks, &s.Today.Clicks, &s.Week.Clicks, &s.Month.Clicks); err != nil {
|
||||||
|
return nil, fmt.Errorf("short url click stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if from != nil && to != nil {
|
||||||
|
var rng model.ShortURLRangeStat
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM shortened_urls WHERE created_at >= $1 AND created_at < $2`,
|
||||||
|
*from, *to).Scan(&rng.Created); err != nil {
|
||||||
|
return nil, fmt.Errorf("short url range created: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM shortened_url_clicks WHERE clicked_at >= $1 AND clicked_at < $2`,
|
||||||
|
*from, *to).Scan(&rng.Clicks); err != nil {
|
||||||
|
return nil, fmt.Errorf("short url range clicks: %w", err)
|
||||||
|
}
|
||||||
|
s.Range = &rng
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AudioProgressRepo wraps Postgres access for audio_progress — the audio
|
||||||
|
// player's per-file resume points. One row per (user, audio_key).
|
||||||
|
type AudioProgressRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewAudioProgressRepo(pg *Postgres) *AudioProgressRepo {
|
||||||
|
return &AudioProgressRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all of the user's saved resume points, newest first.
|
||||||
|
// Used to hydrate the player's in-memory map on sign-in.
|
||||||
|
func (r *AudioProgressRepo) ListByUser(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT audio_key, position_seconds, duration_seconds, updated_at
|
||||||
|
FROM audio_progress
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list audio progress: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []model.AudioProgress
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.AudioProgress
|
||||||
|
if err := rows.Scan(&p.Key, &p.Position, &p.Duration, &p.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan audio progress: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert writes a batch of resume points. Each row is inserted or updated
|
||||||
|
// only when the incoming updated_at is at least as new as the stored one
|
||||||
|
// (newest-wins), so concurrent tabs/devices and the on-login sync of
|
||||||
|
// anonymous IndexedDB entries never clobber a fresher position.
|
||||||
|
func (r *AudioProgressRepo) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch := &pgx.Batch{}
|
||||||
|
for _, it := range items {
|
||||||
|
batch.Queue(`
|
||||||
|
INSERT INTO audio_progress
|
||||||
|
(user_id, audio_key, position_seconds, duration_seconds, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, audio_key) DO UPDATE
|
||||||
|
SET position_seconds = EXCLUDED.position_seconds,
|
||||||
|
duration_seconds = EXCLUDED.duration_seconds,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
WHERE EXCLUDED.updated_at >= audio_progress.updated_at`,
|
||||||
|
userID, it.Key, it.Position, it.Duration, it.UpdatedAt)
|
||||||
|
}
|
||||||
|
br := r.pg.Pool.SendBatch(ctx, batch)
|
||||||
|
defer br.Close()
|
||||||
|
for range items {
|
||||||
|
if _, err := br.Exec(); err != nil {
|
||||||
|
return fmt.Errorf("upsert audio progress: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a single resume point (called when a track plays to
|
||||||
|
// completion so it restarts from the beginning next time).
|
||||||
|
func (r *AudioProgressRepo) Delete(ctx context.Context, userID, key string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM audio_progress WHERE user_id = $1 AND audio_key = $2`,
|
||||||
|
userID, key,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete audio progress: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These exercise real SQL against a real Postgres — the LATERAL joins, the
|
||||||
|
// FOR UPDATE sequencing and the partial indexes are the parts most likely to be
|
||||||
|
// wrong, and none of them can be checked without a database. Skipped when no
|
||||||
|
// POSTGRES_URI is configured, so `go test ./...` still passes anywhere.
|
||||||
|
func testRepo(t *testing.T) (*ChatRepo, *Postgres) {
|
||||||
|
t.Helper()
|
||||||
|
uri := os.Getenv("POSTGRES_URI")
|
||||||
|
if uri == "" {
|
||||||
|
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
|
||||||
|
uri = os.Getenv("POSTGRES_URI")
|
||||||
|
}
|
||||||
|
if uri == "" {
|
||||||
|
t.Skip("POSTGRES_URI not set — skipping chat repository integration tests")
|
||||||
|
}
|
||||||
|
pg, err := NewPostgres(context.Background(), uri, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unreachable: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pg.Close)
|
||||||
|
return NewChatRepo(pg), pg
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeUser inserts a throwaway user and removes it (and anything cascading
|
||||||
|
// from it) when the test finishes.
|
||||||
|
func makeUser(t *testing.T, pg *Postgres, label string) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
id := uuid.NewString()
|
||||||
|
suffix := id[:8]
|
||||||
|
_, err := pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO users (id, email, password_hash, name, handle)
|
||||||
|
VALUES ($1, $2, 'x', $3, $4)`,
|
||||||
|
id, fmt.Sprintf("chattest-%s@example.invalid", suffix), "Chat Test "+label, "chattest_"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create test user: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropConversation removes a thread when the test ends; messages, participants
|
||||||
|
// and attachments cascade with it.
|
||||||
|
func dropConversation(t *testing.T, pg *Postgres, id string) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(t *testing.T, r *ChatRepo, convID, sender, body string) *model.Message {
|
||||||
|
t.Helper()
|
||||||
|
m, _, err := r.Append(context.Background(), AppendParams{
|
||||||
|
ConversationID: convID, SenderID: &sender, Kind: model.MessageText, Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append %q: %v", body, err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDM_IsIdempotentFromEitherSide(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
|
||||||
|
first, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDM: %v", err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, first.ID)
|
||||||
|
|
||||||
|
// Same pair, opposite argument order — must resolve to the same thread,
|
||||||
|
// which is the whole point of the sorted dm_key.
|
||||||
|
second, err := r.EnsureDM(ctx, b, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDM reversed: %v", err)
|
||||||
|
}
|
||||||
|
if second.ID != first.ID {
|
||||||
|
t.Errorf("reversed EnsureDM made a second thread: %s vs %s", second.ID, first.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range []string{a, b} {
|
||||||
|
if _, err := r.Participant(ctx, first.ID, u); err != nil {
|
||||||
|
t.Errorf("user %s is not a participant: %v", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticipant_RejectsOutsiders(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b, outsider := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
|
||||||
|
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
|
||||||
|
if _, err := r.Participant(ctx, c.ID, outsider); !errors.Is(err, ErrNotParticipant) {
|
||||||
|
t.Errorf("outsider got %v, want ErrNotParticipant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_AssignsGaplessSequence(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
|
||||||
|
for i := int64(1); i <= 3; i++ {
|
||||||
|
m := send(t, r, c.ID, a, fmt.Sprintf("msg %d", i))
|
||||||
|
if m.Seq != i {
|
||||||
|
t.Errorf("message %d got seq %d", i, m.Seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSeq int64
|
||||||
|
if err := pg.Pool.QueryRow(ctx, `SELECT last_seq FROM conversations WHERE id=$1`, c.ID).Scan(&lastSeq); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lastSeq != 3 {
|
||||||
|
t.Errorf("conversation.last_seq = %d, want 3", lastSeq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A retried send must return the original message and must NOT burn a sequence
|
||||||
|
// number — a gap would break the client's catch-up cursor.
|
||||||
|
func TestAppend_DeduplicatesRetries(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
|
||||||
|
clientID := uuid.NewString()
|
||||||
|
p := AppendParams{ConversationID: c.ID, SenderID: &a, Kind: model.MessageText,
|
||||||
|
Body: "only once", ClientMsgID: &clientID}
|
||||||
|
|
||||||
|
first, created, err := r.Append(ctx, p)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("first append: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
again, created, err := r.Append(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry: %v", err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Error("retry reported created=true")
|
||||||
|
}
|
||||||
|
if again.ID != first.ID || again.Seq != first.Seq {
|
||||||
|
t.Errorf("retry returned a different message: %s/%d vs %s/%d", again.ID, again.Seq, first.ID, first.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count, lastSeq int64
|
||||||
|
_ = pg.Pool.QueryRow(ctx, `SELECT count(*), max(seq) FROM messages WHERE conversation_id=$1`, c.ID).Scan(&count, &lastSeq)
|
||||||
|
if count != 1 || lastSeq != 1 {
|
||||||
|
t.Errorf("after retry: %d messages, last seq %d — want 1 and 1", count, lastSeq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessages_KeysetPaging(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
send(t, r, c.ID, a, fmt.Sprintf("m%d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
page1, err := r.Messages(ctx, c.ID, a, 0, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page1) != 2 || page1[0].Seq != 5 || page1[1].Seq != 4 {
|
||||||
|
t.Fatalf("page1 = %v, want seq 5,4", seqsOf(page1))
|
||||||
|
}
|
||||||
|
page2, err := r.Messages(ctx, c.ID, a, page1[len(page1)-1].Seq, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page2) != 2 || page2[0].Seq != 3 || page2[1].Seq != 2 {
|
||||||
|
t.Fatalf("page2 = %v, want seq 3,2", seqsOf(page2))
|
||||||
|
}
|
||||||
|
// entities must come back as a JSON array, never null — the client indexes
|
||||||
|
// into it without a nil check.
|
||||||
|
var spans []map[string]any
|
||||||
|
if err := json.Unmarshal(page1[0].Entities, &spans); err != nil {
|
||||||
|
t.Errorf("entities not valid JSON: %v (%s)", err, page1[0].Entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete-for-me must hide a message from that user's history AND from their
|
||||||
|
// inbox preview, while leaving it visible to everyone else.
|
||||||
|
func TestMessageHides_ArePerUser(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
send(t, r, c.ID, a, "keep")
|
||||||
|
hidden := send(t, r, c.ID, a, "hide me")
|
||||||
|
|
||||||
|
if _, err := pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO message_hides (message_id, user_id) VALUES ($1,$2)`, hidden.ID, a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mine, _ := r.Messages(ctx, c.ID, a, 0, 10)
|
||||||
|
if len(mine) != 1 || mine[0].Body != "keep" {
|
||||||
|
t.Errorf("hider still sees %v", bodiesOf(mine))
|
||||||
|
}
|
||||||
|
theirs, _ := r.Messages(ctx, c.ID, b, 0, 10)
|
||||||
|
if len(theirs) != 2 {
|
||||||
|
t.Errorf("other participant sees %v, want both", bodiesOf(theirs))
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, err := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := findConv(inbox, c.ID)
|
||||||
|
if row == nil || row.LastMessage == nil {
|
||||||
|
t.Fatal("conversation missing from hider's inbox")
|
||||||
|
}
|
||||||
|
if row.LastMessage.Body != "keep" {
|
||||||
|
t.Errorf("hider's preview = %q, want the un-hidden message", row.LastMessage.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInbox_UnreadPeerAndPreview(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
send(t, r, c.ID, a, "first")
|
||||||
|
send(t, r, c.ID, a, "latest")
|
||||||
|
|
||||||
|
// Recipient has read nothing.
|
||||||
|
inbox, err := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := findConv(inbox, c.ID)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatal("thread missing from recipient inbox")
|
||||||
|
}
|
||||||
|
if row.Unread != 2 {
|
||||||
|
t.Errorf("unread = %d, want 2", row.Unread)
|
||||||
|
}
|
||||||
|
if row.LastMessage == nil || row.LastMessage.Body != "latest" {
|
||||||
|
t.Errorf("preview = %+v, want body %q", row.LastMessage, "latest")
|
||||||
|
}
|
||||||
|
if row.LastMessage != nil && row.LastMessage.SenderName == "" {
|
||||||
|
t.Error("preview has no sender name — the inbox line needs it")
|
||||||
|
}
|
||||||
|
if row.Peer == nil || row.Peer.ID != a {
|
||||||
|
t.Errorf("peer = %+v, want user A", row.Peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sender's own cursor advanced on send, so their unread is zero.
|
||||||
|
senderInbox, _ := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
|
||||||
|
if sr := findConv(senderInbox, c.ID); sr == nil || sr.Unread != 0 {
|
||||||
|
t.Errorf("sender unread = %v, want 0", sr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.MarkRead(ctx, c.ID, b, 2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
|
||||||
|
if row := findConv(after, c.ID); row == nil || row.Unread != 0 {
|
||||||
|
t.Errorf("after MarkRead unread = %v, want 0", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursors must never move backwards: a replayed or out-of-order receipt would
|
||||||
|
// otherwise resurrect already-read messages as unread.
|
||||||
|
func TestCursors_NeverGoBackwards(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
|
||||||
|
c, err := r.EnsureDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, c.ID)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
send(t, r, c.ID, a, "m")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.MarkRead(ctx, c.ID, b, 3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.MarkRead(ctx, c.ID, b, 1); err != nil { // stale replay
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := r.Participant(ctx, c.ID, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.LastReadSeq != 3 {
|
||||||
|
t.Errorf("last_read_seq = %d after a stale replay, want 3", p.LastReadSeq)
|
||||||
|
}
|
||||||
|
if p.LastDeliveredSeq != 3 {
|
||||||
|
t.Errorf("reading should imply delivery: last_delivered_seq = %d, want 3", p.LastDeliveredSeq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceipts_ExcludeCallerAndUseMinimum(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b, cUser := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
|
||||||
|
conv, err := r.CreateGroup(ctx, a, "Test group", []string{b, cUser})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, conv.ID)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
send(t, r, conv.ID, a, "m")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.MarkDelivered(ctx, conv.ID, b, 3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.MarkRead(ctx, conv.ID, b, 3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// C has received nothing yet.
|
||||||
|
rc, err := r.Receipts(ctx, conv.ID, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rc.ReadSeq != 0 || rc.DeliveredSeq != 0 {
|
||||||
|
t.Errorf("receipts = %+v, want zeros while one member is behind", rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.MarkRead(ctx, conv.ID, cUser, 3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rc, _ = r.Receipts(ctx, conv.ID, a)
|
||||||
|
if rc.ReadSeq != 3 || rc.DeliveredSeq != 3 {
|
||||||
|
t.Errorf("receipts = %+v, want 3/3 once everyone has read", rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAIThread_OnePerUserAndSeparateTab(t *testing.T) {
|
||||||
|
r, pg := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a := makeUser(t, pg, "A")
|
||||||
|
|
||||||
|
first, err := r.EnsureAIThread(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropConversation(t, pg, first.ID)
|
||||||
|
again, err := r.EnsureAIThread(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.ID != first.ID {
|
||||||
|
t.Errorf("second call made another AI thread: %s vs %s", again.ID, first.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tabs are a kind filter: an AI thread must not appear under Chats.
|
||||||
|
chats, _ := r.Inbox(ctx, a, []string{model.ConversationDM, model.ConversationGroup}, 10)
|
||||||
|
if findConv(chats, first.ID) != nil {
|
||||||
|
t.Error("AI thread leaked into the Chats tab")
|
||||||
|
}
|
||||||
|
kautilya, _ := r.Inbox(ctx, a, []string{model.ConversationAI}, 10)
|
||||||
|
if findConv(kautilya, first.ID) == nil {
|
||||||
|
t.Error("AI thread missing from the Kautilya tab")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findConv(items []model.InboxItem, id string) *model.InboxItem {
|
||||||
|
for i := range items {
|
||||||
|
if items[i].ID == id {
|
||||||
|
return &items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seqsOf(msgs []model.Message) []int64 {
|
||||||
|
out := make([]int64, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
out[i] = m.Seq
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func bodiesOf(msgs []model.Message) []string {
|
||||||
|
out := make([]string, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
out[i] = m.Body
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -88,6 +89,60 @@ func (r *DiscipleRepo) GetByLetterAndSuffix(ctx context.Context, letter *string,
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByIDs returns summary docs for the given ObjectID hex strings (order
|
||||||
|
// unspecified). Ids that aren't valid ObjectIDs are skipped rather than
|
||||||
|
// failing the batch — the ids come from a Postgres column with no FK, so a
|
||||||
|
// malformed or stale value must degrade gracefully. An empty/all-invalid
|
||||||
|
// input short-circuits without hitting Mongo.
|
||||||
|
//
|
||||||
|
// Used to hydrate disciple names onto admin user rows: the user→disciple link
|
||||||
|
// lives in Postgres while the disciple record lives here.
|
||||||
|
func (r *DiscipleRepo) FindByIDs(ctx context.Context, ids []string) ([]DiscipleSummaryDoc, error) {
|
||||||
|
oids := make([]bson.ObjectID, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if oid, err := bson.ObjectIDFromHex(id); err == nil {
|
||||||
|
oids = append(oids, oid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(oids) == 0 {
|
||||||
|
return []DiscipleSummaryDoc{}, nil
|
||||||
|
}
|
||||||
|
cursor, err := r.col.Find(ctx,
|
||||||
|
bson.M{"_id": bson.M{"$in": oids}},
|
||||||
|
options.Find().SetProjection(discipleSummaryProjection),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
var results []DiscipleSummaryDoc
|
||||||
|
if err := cursor.All(ctx, &results); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one disciple summary, or nil when the id is malformed or
|
||||||
|
// no such document exists. Callers use it to validate a link target before
|
||||||
|
// writing it to Postgres.
|
||||||
|
func (r *DiscipleRepo) FindByID(ctx context.Context, id string) (*DiscipleSummaryDoc, error) {
|
||||||
|
oid, err := bson.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil // malformed id — "not found", not an error
|
||||||
|
}
|
||||||
|
var doc DiscipleSummaryDoc
|
||||||
|
err = r.col.FindOne(ctx, bson.M{"_id": oid},
|
||||||
|
options.FindOne().SetProjection(discipleSummaryProjection),
|
||||||
|
).Decode(&doc)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) {
|
func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) {
|
||||||
return r.col.CountDocuments(ctx, bson.M{})
|
return r.col.CountDocuments(ctx, bson.M{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,15 +85,55 @@ func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bo
|
|||||||
return tag.RowsAffected() == 1, nil
|
return tag.RowsAffected() == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListByUser returns a single user's own feedback submissions, newest
|
||||||
|
// first, with status timestamps and any admin reply. Backs the "My
|
||||||
|
// feedback" page under /settings. No author joins — the caller IS the
|
||||||
|
// author, so handle/name/email aren't needed here. Bounded limit keeps
|
||||||
|
// the query cheap.
|
||||||
|
func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, category, message, contact_email, created_at,
|
||||||
|
acknowledged_at, resolved_at, replied_at, reply_message
|
||||||
|
FROM feedback
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2`, userID, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query user feedback: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Feedback{}
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.Feedback
|
||||||
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
|
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user feedback: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Recent returns the most-recent N feedback rows joined with users
|
// Recent returns the most-recent N feedback rows joined with users
|
||||||
// for display on /stats. NULL author columns mean anonymous (or the
|
// for display on /stats. NULL author columns mean anonymous (or the
|
||||||
// original submitter's account was since deleted).
|
// original submitter's account was since deleted).
|
||||||
|
//
|
||||||
|
// PRIVACY: /stats is a public page, so this query deliberately does NOT select
|
||||||
|
// contact_email — only a boolean saying whether one exists. Submitters' email
|
||||||
|
// addresses must never reach that template, and not fetching them makes that
|
||||||
|
// impossible rather than merely unlikely. Callers that legitimately need the
|
||||||
|
// address (the reply/thank-you emails) use FindByID instead.
|
||||||
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
|
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 25
|
limit = 25
|
||||||
}
|
}
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
SELECT f.id, f.user_id, f.category, f.message, f.created_at,
|
||||||
|
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
|
||||||
f.acknowledged_at, f.resolved_at,
|
f.acknowledged_at, f.resolved_at,
|
||||||
u.handle, u.name
|
u.handle, u.name
|
||||||
FROM feedback f
|
FROM feedback f
|
||||||
@@ -108,7 +148,8 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
|
|||||||
out := []model.Feedback{}
|
out := []model.Feedback{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f model.Feedback
|
var f model.Feedback
|
||||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
|
||||||
|
&f.HasContactEmail,
|
||||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||||
&f.AuthorHandle, &f.AuthorName); err != nil {
|
&f.AuthorHandle, &f.AuthorName); err != nil {
|
||||||
return nil, fmt.Errorf("scan feedback: %w", err)
|
return nil, fmt.Errorf("scan feedback: %w", err)
|
||||||
|
|||||||
@@ -213,13 +213,41 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
// id DESC tiebreak must match ListByUserKeyset so the scroll feed's first
|
||||||
|
// (offset) page and its keyset continuation agree on the order of
|
||||||
|
// same-created_at posts — otherwise the keyset page re-returns already-shown
|
||||||
|
// rows, the client's cursor never advances, and it loops forever.
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
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 DESC
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListByUserKeyset returns the user's visible posts older than the cursor
|
||||||
|
// (created_at, id), newest-first — keyset pagination for the Instagram-style
|
||||||
|
// scroll feed. A zero beforeAt returns the newest page. No total is computed
|
||||||
|
// (infinite scroll infers "more" from a full page, like FeedForViewer). The
|
||||||
|
// (created_at, id) DESC ordering matches the tuple comparison so paging never
|
||||||
|
// skips or repeats a row even when timestamps tie.
|
||||||
|
func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||||
|
base := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
|
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`
|
||||||
|
if beforeAt.IsZero() {
|
||||||
|
q := base + `
|
||||||
|
ORDER BY p.created_at DESC, p.id DESC
|
||||||
|
LIMIT $2`
|
||||||
|
posts, _, err := r.collectPosts(ctx, 0, q, userID, limit)
|
||||||
|
return posts, err
|
||||||
|
}
|
||||||
|
q := base + `
|
||||||
|
AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3))
|
||||||
|
ORDER BY p.created_at DESC, p.id DESC
|
||||||
|
LIMIT $4`
|
||||||
|
posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit)
|
||||||
|
return posts, err
|
||||||
|
}
|
||||||
|
|
||||||
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
||||||
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
|
// /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) {
|
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
@@ -286,8 +314,9 @@ func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, of
|
|||||||
return posts, err
|
return posts, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer
|
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer /
|
||||||
// across a page of feed posts in two batched queries (one per direction).
|
// ViewerFollowRequested across a page of feed posts in three batched queries
|
||||||
|
// (viewer→author accepted, author→viewer accepted, viewer→author pending).
|
||||||
// No-op for anonymous viewers.
|
// No-op for anonymous viewers.
|
||||||
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
if viewerID == "" || len(posts) == 0 {
|
if viewerID == "" || len(posts) == 0 {
|
||||||
@@ -320,9 +349,17 @@ func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
requested, err := r.followEdges(ctx,
|
||||||
|
`SELECT following_id FROM follows
|
||||||
|
WHERE follower_id = $1 AND status = 'pending' AND following_id = ANY($2)`,
|
||||||
|
viewerID, authorIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
||||||
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
||||||
|
posts[i].ViewerFollowRequested = requested[posts[i].UserID]
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-3
@@ -25,6 +25,11 @@ type R2 struct {
|
|||||||
client *s3.Client
|
client *s3.Client
|
||||||
bucket string
|
bucket string
|
||||||
publicBase string // R2_PUBLIC_URL, no trailing slash
|
publicBase string // R2_PUBLIC_URL, no trailing slash
|
||||||
|
|
||||||
|
// privateBucket is a separate, non-public bucket for objects that must
|
||||||
|
// only be reachable through a presigned GET (see PresignGet). Empty when
|
||||||
|
// unconfigured, which makes PrivateConfigured report false.
|
||||||
|
privateBucket string
|
||||||
}
|
}
|
||||||
|
|
||||||
// R2Config captures the env vars needed to talk to R2.
|
// R2Config captures the env vars needed to talk to R2.
|
||||||
@@ -34,6 +39,9 @@ type R2Config struct {
|
|||||||
SecretAccessKey string
|
SecretAccessKey string
|
||||||
Bucket string
|
Bucket string
|
||||||
PublicURL string
|
PublicURL string
|
||||||
|
// PrivateBucket is optional: absent means private media is disabled,
|
||||||
|
// while the public bucket keeps working.
|
||||||
|
PrivateBucket string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured returns true if every required field is present.
|
// Configured returns true if every required field is present.
|
||||||
@@ -61,9 +69,10 @@ func NewR2(cfg R2Config) (*R2, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return &R2{
|
return &R2{
|
||||||
client: client,
|
client: client,
|
||||||
bucket: cfg.Bucket,
|
bucket: cfg.Bucket,
|
||||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||||
|
privateBucket: cfg.PrivateBucket,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,3 +138,91 @@ func trimTrailingSlash(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private media
|
||||||
|
//
|
||||||
|
// Objects in the public bucket are world-readable by URL — fine for avatars
|
||||||
|
// and post images, wrong for anything private (DM attachments). R2 public
|
||||||
|
// access is per-bucket, not per-prefix, so private objects live in a
|
||||||
|
// SEPARATE bucket that has no public access and no custom domain. The only
|
||||||
|
// way to read them is a short-lived presigned GET, which the BFF issues after
|
||||||
|
// checking that the caller is allowed to see that particular object.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PrivateConfigured reports whether the private bucket is available. Callers
|
||||||
|
// use it to return 503 rather than silently falling back to public storage —
|
||||||
|
// a fallback there would leak the very objects this bucket exists to protect.
|
||||||
|
func (r *R2) PrivateConfigured() bool {
|
||||||
|
return r != nil && r.privateBucket != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignPutPrivate returns a one-shot PUT URL for the PRIVATE bucket.
|
||||||
|
//
|
||||||
|
// Unlike PresignPut it deliberately returns no public URL: there isn't one,
|
||||||
|
// and handing callers a URL-shaped string for a private object invites it
|
||||||
|
// being persisted and treated as fetchable. Store the key instead, and mint a
|
||||||
|
// read URL per request with PresignGet.
|
||||||
|
func (r *R2) PresignPutPrivate(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL string, err error) {
|
||||||
|
if !r.PrivateConfigured() {
|
||||||
|
return "", errors.New("private media storage is not configured")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("empty key")
|
||||||
|
}
|
||||||
|
|
||||||
|
presigner := s3.NewPresignClient(r.client)
|
||||||
|
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(r.privateBucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
}, s3.WithPresignExpires(expiry))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("presign private put: %w", err)
|
||||||
|
}
|
||||||
|
return req.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGet returns a short-lived read URL for an object in the private
|
||||||
|
// bucket.
|
||||||
|
//
|
||||||
|
// Keep expiry short. The URL is a bearer credential for that one object:
|
||||||
|
// anyone holding it can read until it expires, so it should be minted per
|
||||||
|
// request and never persisted in a message payload or an offline cache.
|
||||||
|
func (r *R2) PresignGet(ctx context.Context, key string, expiry time.Duration) (string, error) {
|
||||||
|
if !r.PrivateConfigured() {
|
||||||
|
return "", errors.New("private media storage is not configured")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("empty key")
|
||||||
|
}
|
||||||
|
|
||||||
|
presigner := s3.NewPresignClient(r.client)
|
||||||
|
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(r.privateBucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
}, s3.WithPresignExpires(expiry))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("presign get: %w", err)
|
||||||
|
}
|
||||||
|
return req.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrivate removes an object from the private bucket. Retention and
|
||||||
|
// account deletion need real erasure, not a tombstone row that leaves the
|
||||||
|
// bytes sitting in storage.
|
||||||
|
func (r *R2) DeletePrivate(ctx context.Context, key string) error {
|
||||||
|
if !r.PrivateConfigured() {
|
||||||
|
return errors.New("private media storage is not configured")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return errors.New("empty key")
|
||||||
|
}
|
||||||
|
if _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(r.privateBucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("delete private object %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,22 +98,29 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string)
|
|||||||
// them), newest first, optionally filtered by a name/handle search `q`.
|
// them), newest first, optionally filtered by a name/handle search `q`.
|
||||||
// Returns the page plus the total matching count (for pagination).
|
// Returns the page plus the total matching count (for pagination).
|
||||||
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", userID, q, limit, offset)
|
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "accepted", userID, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIncomingRequests returns the users with a pending request to follow
|
||||||
|
// userID (newest first), optionally filtered by `q`, plus the total count.
|
||||||
|
func (r *FollowRepo) ListIncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
|
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "pending", userID, q, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFollowing returns the users userID follows (accepted), newest first,
|
// ListFollowing returns the users userID follows (accepted), newest first,
|
||||||
// optionally filtered by `q`. Returns the page plus the total count.
|
// optionally filtered by `q`. Returns the page plus the total count.
|
||||||
func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", userID, q, limit, offset)
|
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", "accepted", userID, q, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listRelations powers both directions: `whereOwn` pins the row to userID on
|
// listRelations powers follower/following/pending-request listings: `whereOwn`
|
||||||
// one side, `joinCol` is the follows column holding the *other* user (the one
|
// pins the row to userID on one side, `joinCol` is the follows column holding
|
||||||
// we join to users and return). `q` filters by name/handle (ILIKE); empty `q`
|
// the *other* user (the one we join to users and return), `status` is the edge
|
||||||
// disables the filter. The column expressions are fixed internal constants —
|
// status to match ('accepted' or 'pending'). `q` filters by name/handle
|
||||||
// never user input — so this stays injection-safe.
|
// (ILIKE); empty `q` disables the filter. The column/status expressions are
|
||||||
|
// fixed internal constants — never user input — so this stays injection-safe.
|
||||||
func (r *FollowRepo) listRelations(
|
func (r *FollowRepo) listRelations(
|
||||||
ctx context.Context, whereOwn, joinCol, userID, q string, limit, offset int,
|
ctx context.Context, whereOwn, joinCol, status, userID, q string, limit, offset int,
|
||||||
) ([]model.UserSummary, int, error) {
|
) ([]model.UserSummary, int, error) {
|
||||||
like := "%" + q + "%"
|
like := "%" + q + "%"
|
||||||
|
|
||||||
@@ -121,7 +128,7 @@ func (r *FollowRepo) listRelations(
|
|||||||
countSQL := `
|
countSQL := `
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
||||||
WHERE ` + whereOwn + ` AND f.status = 'accepted'
|
WHERE ` + whereOwn + ` AND f.status = '` + status + `'
|
||||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`
|
||||||
if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil {
|
if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count relations: %w", err)
|
return nil, 0, fmt.Errorf("count relations: %w", err)
|
||||||
@@ -133,7 +140,7 @@ func (r *FollowRepo) listRelations(
|
|||||||
listSQL := `
|
listSQL := `
|
||||||
SELECT u.handle, u.name, u.avatar_url
|
SELECT u.handle, u.name, u.avatar_url
|
||||||
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
||||||
WHERE ` + whereOwn + ` AND f.status = 'accepted'
|
WHERE ` + whereOwn + ` AND f.status = '` + status + `'
|
||||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||||
ORDER BY f.created_at DESC, u.handle ASC
|
ORDER BY f.created_at DESC, u.handle ASC
|
||||||
LIMIT $4 OFFSET $5`
|
LIMIT $4 OFFSET $5`
|
||||||
@@ -327,6 +334,62 @@ func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastToAllUsers writes one notification of `kind` to every active user
|
||||||
|
// except `actorID`, in a single INSERT…SELECT. One round trip regardless of
|
||||||
|
// audience size — the alternative (a row per user from Go) would be hundreds
|
||||||
|
// of round trips on the request path.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT filter on notification_prefs: ListByUser already
|
||||||
|
// excludes kinds the reader has disabled for the in_app channel at read time,
|
||||||
|
// so writing the rows unconditionally keeps a re-enabled preference able to
|
||||||
|
// surface history — exactly how the existing kinds behave.
|
||||||
|
//
|
||||||
|
// Banned accounts are skipped; they can't sign in to read it anyway.
|
||||||
|
func (r *NotificationRepo) BroadcastToAllUsers(ctx context.Context, kind, actorID string) (int64, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO notifications (user_id, actor_id, kind)
|
||||||
|
SELECT u.id, $2, $1
|
||||||
|
FROM users u
|
||||||
|
WHERE u.id <> $2 AND u.banned_at IS NULL`, kind, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("broadcast notification: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRecipientsForKind lists the ids of users who should receive a *push* for
|
||||||
|
// `kind` — everyone except the actor, minus banned accounts, minus anyone who
|
||||||
|
// turned this kind off for the push channel, and minus anyone with no push
|
||||||
|
// subscription registered at all.
|
||||||
|
//
|
||||||
|
// That last filter is what keeps a broadcast cheap: only devotees who actually
|
||||||
|
// enabled web push are worth iterating, which is typically a small fraction of
|
||||||
|
// the user base.
|
||||||
|
func (r *NotificationRepo) PushRecipientsForKind(ctx context.Context, kind, actorID string) ([]string, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT u.id
|
||||||
|
FROM users u
|
||||||
|
JOIN push_subscriptions ps ON ps.user_id = u.id
|
||||||
|
WHERE u.id <> $2 AND u.banned_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM notification_prefs np
|
||||||
|
WHERE np.user_id = u.id AND np.kind = $1
|
||||||
|
AND np.channel = 'push' AND np.enabled = FALSE)`, kind, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list push recipients: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan push recipient: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListByUser returns a recipient's notifications, newest first, with the
|
// 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). Kinds the user has turned off for
|
// notifications or since-deleted actors). Kinds the user has turned off for
|
||||||
|
|||||||
@@ -17,22 +17,22 @@ func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
|
|||||||
// StatsSnapshot bundles totals, last-7-days counts, and feedback
|
// StatsSnapshot bundles totals, last-7-days counts, and feedback
|
||||||
// category breakdowns. One value rendered by /stats per request.
|
// category breakdowns. One value rendered by /stats per request.
|
||||||
type StatsSnapshot struct {
|
type StatsSnapshot struct {
|
||||||
Users int
|
Users int `json:"users"`
|
||||||
UsersLast7Days int
|
UsersLast7Days int `json:"users_last_7_days"`
|
||||||
Posts int
|
Posts int `json:"posts"`
|
||||||
PostsLast7Days int
|
PostsLast7Days int `json:"posts_last_7_days"`
|
||||||
Collections int
|
Collections int `json:"collections"`
|
||||||
PublicCollections int
|
PublicCollections int `json:"public_collections"`
|
||||||
Bookmarks int
|
Bookmarks int `json:"bookmarks"`
|
||||||
Reshares int
|
Reshares int `json:"reshares"`
|
||||||
Feedback int
|
Feedback int `json:"feedback"`
|
||||||
FeedbackLast7Days int
|
FeedbackLast7Days int `json:"feedback_last_7_days"`
|
||||||
FeedbackByCategory []CategoryCount
|
FeedbackByCategory []CategoryCount `json:"feedback_by_category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryCount struct {
|
type CategoryCount struct {
|
||||||
Category string
|
Category string `json:"category"`
|
||||||
Count int
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot runs every count needed by /stats. Queries run in series —
|
// Snapshot runs every count needed by /stats. Queries run in series —
|
||||||
|
|||||||
@@ -180,6 +180,80 @@ func (r *TranscriptionRepo) GetPlaces(ctx context.Context) ([]bson.M, error) {
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TranscriptionBooks is the canonical set of scriptures that appear in the
|
||||||
|
// free-text `reference` field of transcriptions (Prabhupāda's lectures are
|
||||||
|
// commentaries on specific verses). Browsing by "book" is derived from the
|
||||||
|
// reference string — there are no structured book/chapter columns. Ordered for
|
||||||
|
// display fallbacks; the listing endpoints filter out books with no transcripts.
|
||||||
|
var TranscriptionBooks = []struct{ Code, Title string }{
|
||||||
|
{"SB", "Śrīmad-Bhāgavatam"},
|
||||||
|
{"BG", "Bhagavad-gītā"},
|
||||||
|
{"CC", "Caitanya-caritāmṛta"},
|
||||||
|
{"BS", "Brahma-saṁhitā"},
|
||||||
|
{"NOD", "The Nectar of Devotion"},
|
||||||
|
{"ISO", "Śrī Īśopaniṣad"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookTitle returns the display title for a known book code, or "" if unknown.
|
||||||
|
func bookTitle(code string) string {
|
||||||
|
for _, b := range TranscriptionBooks {
|
||||||
|
if b.Code == code {
|
||||||
|
return b.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookRefFilter matches published transcriptions whose reference begins with the
|
||||||
|
// given book code. The trailing "([^A-Za-z]|$)" is a boundary so e.g. "BS" can't
|
||||||
|
// match "BG"; no code is a prefix of another, so this is unambiguous.
|
||||||
|
func bookRefFilter(code string) bson.M {
|
||||||
|
return bson.M{
|
||||||
|
"isPublished": true,
|
||||||
|
"reference": bson.M{"$regex": "^" + code + "([^A-Za-z]|$)"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBooks returns the books that have at least one transcript, as
|
||||||
|
// {_id, title, count} — mirroring the {_id, count} shape the frontend already
|
||||||
|
// consumes for years/places. One count query per book (like GetTypeDistribution).
|
||||||
|
func (r *TranscriptionRepo) GetBooks(ctx context.Context) ([]bson.M, error) {
|
||||||
|
results := make([]bson.M, 0, len(TranscriptionBooks))
|
||||||
|
for _, b := range TranscriptionBooks {
|
||||||
|
count, err := r.col.CountDocuments(ctx, bookRefFilter(b.Code))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
results = append(results, bson.M{"_id": b.Code, "title": b.Title, "count": count})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByBook lists the transcript summaries for a book code, sorted
|
||||||
|
// chronologically (same projection/sort as GetByType).
|
||||||
|
func (r *TranscriptionRepo) GetByBook(ctx context.Context, code string) ([]TranscriptionSummaryDoc, error) {
|
||||||
|
opts := options.Find().
|
||||||
|
SetSort(bson.D{{Key: "year", Value: 1}, {Key: "month", Value: 1}, {Key: "day", Value: 1}}).
|
||||||
|
SetProjection(transcriptionSummaryProjection)
|
||||||
|
cursor, err := r.col.Find(ctx, bookRefFilter(code), opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
var results []TranscriptionSummaryDoc
|
||||||
|
if err := cursor.All(ctx, &results); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookTitle exposes the registry lookup to the handler for validation.
|
||||||
|
func (r *TranscriptionRepo) BookTitle(code string) string {
|
||||||
|
return bookTitle(code)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *TranscriptionRepo) GetTypeDistribution(ctx context.Context) (int64, int64, error) {
|
func (r *TranscriptionRepo) GetTypeDistribution(ctx context.Context) (int64, int64, error) {
|
||||||
lectureCount, err := r.col.CountDocuments(ctx, bson.M{"type": "lecture", "isPublished": true})
|
lectureCount, err := r.col.CountDocuments(ctx, bson.M{"type": "lecture", "isPublished": true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ 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, banned_at`
|
preferred_chanting_time, peak_active_time, banned_at,
|
||||||
|
bookmark_limit, public_collection_limit, private_collection_limit,
|
||||||
|
disciple_id, bio`
|
||||||
|
|
||||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
// 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.
|
||||||
@@ -51,6 +53,8 @@ func scanUser(row rowScanner, u *model.User) error {
|
|||||||
&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.BannedAt,
|
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||||
|
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
|
||||||
|
&u.DiscipleID, &u.Bio,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ type ProfileUpdate struct {
|
|||||||
Name *string
|
Name *string
|
||||||
AvatarURL *string
|
AvatarURL *string
|
||||||
Handle *string
|
Handle *string
|
||||||
|
Bio *string // nil=leave, ""=clear, text=set
|
||||||
Country *string
|
Country *string
|
||||||
Sampradaya *string // nil=leave, ""=clear, code=set
|
Sampradaya *string // nil=leave, ""=clear, code=set
|
||||||
Gender *string
|
Gender *string
|
||||||
@@ -228,6 +233,7 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate
|
|||||||
set("handle", *p.Handle)
|
set("handle", *p.Handle)
|
||||||
}
|
}
|
||||||
nullableString("avatar_url", p.AvatarURL)
|
nullableString("avatar_url", p.AvatarURL)
|
||||||
|
nullableString("bio", p.Bio)
|
||||||
nullableString("country", p.Country)
|
nullableString("country", p.Country)
|
||||||
nullableString("sampradaya", p.Sampradaya)
|
nullableString("sampradaya", p.Sampradaya)
|
||||||
nullableString("gender", p.Gender)
|
nullableString("gender", p.Gender)
|
||||||
@@ -400,6 +406,41 @@ func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
|
|||||||
return handles, rows.Err()
|
return handles, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiscipleLink pairs a Mongo disciple id with the platform account an admin
|
||||||
|
// has linked to it. Powers the /disciples browser highlight: the reader page
|
||||||
|
// fetches the whole (small) set once and marks the matching cards.
|
||||||
|
type DiscipleLink struct {
|
||||||
|
DiscipleID string `json:"disciple_id"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
|
||||||
|
// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
|
||||||
|
// rows at most and the partial index on disciple_id keeps it a cheap scan.
|
||||||
|
// Suspended accounts are excluded so a banned user can't keep a public badge.
|
||||||
|
func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT disciple_id, handle, name, avatar_url
|
||||||
|
FROM users
|
||||||
|
WHERE disciple_id IS NOT NULL AND banned_at IS NULL
|
||||||
|
ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list disciple links: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DiscipleLink{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l DiscipleLink
|
||||||
|
if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan disciple link: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
|
// 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"
|
// 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
|
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||||
|
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||||
|
// verses / prose_chapters / transcriptions collections).
|
||||||
|
type VaniRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
mongo *MongoDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVaniRepo(pg *Postgres, m *MongoDB) *VaniRepo {
|
||||||
|
return &VaniRepo{pg: pg, mongo: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Postgres: the per-day record ---
|
||||||
|
|
||||||
|
// GetForDay returns the pick stored for the given UTC day, or (nil, nil) when
|
||||||
|
// nothing has been chosen yet.
|
||||||
|
func (r *VaniRepo) GetForDay(ctx context.Context, day time.Time) (*model.VaniPick, error) {
|
||||||
|
var kind, refID string
|
||||||
|
var payload []byte
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT kind, ref_id, payload FROM vani_of_the_day WHERE day = $1`, day,
|
||||||
|
).Scan(&kind, &refID, &payload)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get vani for day: %w", err)
|
||||||
|
}
|
||||||
|
var p model.VaniPayload
|
||||||
|
if err := json.Unmarshal(payload, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||||
|
}
|
||||||
|
return &model.VaniPick{Day: day, Kind: kind, RefID: refID, Payload: p}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRefIDs returns the ref_ids chosen on or after `since` — the 90-day
|
||||||
|
// exclusion set for the picker.
|
||||||
|
func (r *VaniRepo) RecentRefIDs(ctx context.Context, since time.Time) ([]string, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx,
|
||||||
|
`SELECT ref_id FROM vani_of_the_day WHERE day >= $1`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recent vani ref ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert stores the day's pick, ignoring a race where another request already
|
||||||
|
// wrote today's (ON CONFLICT (day) DO NOTHING). Callers re-read GetForDay to
|
||||||
|
// get the authoritative row.
|
||||||
|
func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
||||||
|
b, err := json.Marshal(pick.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
|
ON CONFLICT (day) DO NOTHING`,
|
||||||
|
pick.Day, pick.Kind, pick.RefID, b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert vani pick: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||||
|
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||||
|
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||||
|
// as bson.D, so a `raw["data"].(bson.M)` assertion would silently fail).
|
||||||
|
func (r *VaniRepo) BookTitle(ctx context.Context, book string) string {
|
||||||
|
var doc struct {
|
||||||
|
Data struct {
|
||||||
|
Title string `bson:"title"`
|
||||||
|
} `bson:"data"`
|
||||||
|
}
|
||||||
|
if err := r.mongo.Collection("book_meta").
|
||||||
|
FindOne(ctx, bson.D{{Key: "book", Value: book}}).Decode(&doc); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return doc.Data.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mongo: random candidate sampling ---
|
||||||
|
|
||||||
|
// excludeIDs turns hex _id strings into a $nin ObjectID array.
|
||||||
|
func excludeIDs(hexes []string) bson.A {
|
||||||
|
arr := bson.A{}
|
||||||
|
for _, h := range hexes {
|
||||||
|
if oid, err := bson.ObjectIDFromHex(h); err == nil {
|
||||||
|
arr = append(arr, oid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out interface{}) (bool, error) {
|
||||||
|
cur, err := r.mongo.Collection(coll).Aggregate(ctx, mongo.Pipeline{
|
||||||
|
bson.D{{Key: "$match", Value: match}},
|
||||||
|
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: 1}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("sample %s: %w", coll, err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
if cur.Next(ctx) {
|
||||||
|
if err := cur.Decode(out); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, cur.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleVerse returns a random verse with a translation, excluding the given
|
||||||
|
// _id hexes. Returns nil when nothing matches.
|
||||||
|
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||||
|
match := bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||||
|
{Key: "translation", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$ne", Value: ""}}},
|
||||||
|
}
|
||||||
|
var d VerseDoc
|
||||||
|
ok, err := r.sampleOne(ctx, "verses", match, &d)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleProse returns a random prose chapter with body text, excluding the
|
||||||
|
// given _id hexes.
|
||||||
|
func (r *VaniRepo) SampleProse(ctx context.Context, excludeHex []string) (*ProseChapterDoc, error) {
|
||||||
|
match := bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||||
|
{Key: "paragraphs.0", Value: bson.D{{Key: "$exists", Value: true}}},
|
||||||
|
}
|
||||||
|
var d ProseChapterDoc
|
||||||
|
ok, err := r.sampleOne(ctx, "prose_chapters", match, &d)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleTranscript returns a random published transcript with content,
|
||||||
|
// excluding the given _id hexes.
|
||||||
|
func (r *VaniRepo) SampleTranscript(ctx context.Context, excludeHex []string) (*TranscriptionDoc, error) {
|
||||||
|
match := bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||||
|
{Key: "isPublished", Value: true},
|
||||||
|
{Key: "content.0", Value: bson.D{{Key: "$exists", Value: true}}},
|
||||||
|
}
|
||||||
|
var d TranscriptionDoc
|
||||||
|
ok, err := r.sampleOne(ctx, "transcriptions", match, &d)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
+156
-17
@@ -34,6 +34,11 @@ type Server struct {
|
|||||||
pg *repository.Postgres
|
pg *repository.Postgres
|
||||||
redis *repository.Redis
|
redis *repository.Redis
|
||||||
|
|
||||||
|
// discipleRepo is held on the struct (unlike the other Mongo repos, which
|
||||||
|
// stay local to New) because initAuth re-builds discipleH with the
|
||||||
|
// Postgres userRepo once it exists — see the direct-disciple link feature.
|
||||||
|
discipleRepo *repository.DiscipleRepo
|
||||||
|
|
||||||
// Worker lifecycle
|
// Worker lifecycle
|
||||||
workerCancel context.CancelFunc
|
workerCancel context.CancelFunc
|
||||||
workerCtx context.Context // derived in initShortener; cancelled on Close
|
workerCtx context.Context // derived in initShortener; cancelled on Close
|
||||||
@@ -49,11 +54,13 @@ type Server struct {
|
|||||||
otherDocH *handler.OtherDocumentHandler
|
otherDocH *handler.OtherDocumentHandler
|
||||||
transcriptionH *handler.TranscriptionHandler
|
transcriptionH *handler.TranscriptionHandler
|
||||||
searchH *handler.SearchHandler
|
searchH *handler.SearchHandler
|
||||||
|
vaniH *handler.VaniHandler
|
||||||
sitemapH *handler.SitemapHandler
|
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
|
||||||
|
audioH *handler.AudioProgressHandler
|
||||||
postH *handler.PostHandler
|
postH *handler.PostHandler
|
||||||
commentH *handler.CommentHandler
|
commentH *handler.CommentHandler
|
||||||
bookmarkH *handler.BookmarkHandler
|
bookmarkH *handler.BookmarkHandler
|
||||||
@@ -61,6 +68,8 @@ type Server struct {
|
|||||||
profileH *handler.ProfileHandler
|
profileH *handler.ProfileHandler
|
||||||
relationH *handler.RelationshipHandler
|
relationH *handler.RelationshipHandler
|
||||||
soketiWebhookH *handler.SoketiWebhookHandler
|
soketiWebhookH *handler.SoketiWebhookHandler
|
||||||
|
broadcastAuthH *handler.BroadcastingAuthHandler
|
||||||
|
chatH *handler.ChatHandler
|
||||||
notificationH *handler.NotificationHandler
|
notificationH *handler.NotificationHandler
|
||||||
sadhnaH *handler.SadhnaHandler
|
sadhnaH *handler.SadhnaHandler
|
||||||
pushH *handler.PushHandler
|
pushH *handler.PushHandler
|
||||||
@@ -101,12 +110,15 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
mongo: mongoDB,
|
mongo: mongoDB,
|
||||||
|
|
||||||
|
discipleRepo: discipleRepo,
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
bookH: handler.NewBookHandler(bookRepo),
|
bookH: handler.NewBookHandler(bookRepo),
|
||||||
letterH: handler.NewLetterHandler(letterRepo),
|
letterH: handler.NewLetterHandler(letterRepo),
|
||||||
essayH: handler.NewEssayHandler(essayRepo),
|
essayH: handler.NewEssayHandler(essayRepo),
|
||||||
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
||||||
discipleH: handler.NewDiscipleHandler(discipleRepo),
|
// userRepo half is attached later in initAuth (Postgres isn't up yet).
|
||||||
|
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
|
||||||
journalH: handler.NewJournalHandler(journalRepo),
|
journalH: handler.NewJournalHandler(journalRepo),
|
||||||
magazineH: handler.NewMagazineHandler(magazineRepo),
|
magazineH: handler.NewMagazineHandler(magazineRepo),
|
||||||
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
||||||
@@ -147,6 +159,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
SecretAccessKey: cfg.R2SecretAccessKey,
|
SecretAccessKey: cfg.R2SecretAccessKey,
|
||||||
Bucket: cfg.R2Bucket,
|
Bucket: cfg.R2Bucket,
|
||||||
PublicURL: cfg.R2PublicURL,
|
PublicURL: cfg.R2PublicURL,
|
||||||
|
PrivateBucket: cfg.R2PrivateBucket,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
||||||
@@ -194,6 +207,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||||
s.userH = handler.NewUserHandler(userSvc)
|
s.userH = handler.NewUserHandler(userSvc)
|
||||||
|
|
||||||
|
// Re-wire the disciple handler now that Postgres-backed users exist: its
|
||||||
|
// /disciples/linked route joins the Mongo disciple records to the platform
|
||||||
|
// accounts an admin has linked to them. Built in New() with a nil userRepo
|
||||||
|
// (Mongo is up before Postgres), so this is where it gets the other half.
|
||||||
|
if s.discipleRepo != nil {
|
||||||
|
s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio playback resume points — per-user, per-file last position that
|
||||||
|
// lets the floating player pick up where it left off across devices.
|
||||||
|
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
|
||||||
|
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
|
||||||
|
|
||||||
|
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
|
||||||
|
// page, chosen from Mongo content and recorded in Postgres so it doesn't
|
||||||
|
// repeat within 90 days.
|
||||||
|
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||||
|
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
||||||
|
|
||||||
// OAuth — wired only when at least one provider is configured. The
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
// registry skips providers with missing creds, so partial configs (e.g.
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
// Google only, no Facebook) work out of the box.
|
// Google only, no Facebook) work out of the box.
|
||||||
@@ -227,6 +259,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
||||||
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
|
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
|
||||||
)
|
)
|
||||||
|
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
||||||
|
chatRepo := repository.NewChatRepo(s.pg)
|
||||||
|
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
|
||||||
|
s.chatH = handler.NewChatHandler(chatSvc)
|
||||||
|
|
||||||
|
// Channel authorization for private-* subscriptions. Rules are explicit
|
||||||
|
// and default-deny — an unlisted channel is refused.
|
||||||
|
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
|
||||||
|
handler.NotificationsRule(),
|
||||||
|
handler.ConversationRule(chatSvc.IsParticipant),
|
||||||
|
)
|
||||||
|
|
||||||
pushRepo := repository.NewPushRepo(s.pg)
|
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)
|
||||||
@@ -247,7 +291,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// 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, notifPrefsRepo, notifSvc, logger, cfg.FrontendURL)
|
reminderSvc := service.NewReminderService(reminderRepo, notifPrefsRepo, userRepo, 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 {
|
||||||
@@ -259,7 +303,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// every 15 minutes (no-op when Redis is unavailable).
|
// every 15 minutes (no-op when Redis is unavailable).
|
||||||
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
|
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
|
||||||
|
|
||||||
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, chatRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
s.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)
|
||||||
@@ -284,13 +328,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
statsRepo := repository.NewStatsRepo(s.pg)
|
statsRepo := repository.NewStatsRepo(s.pg)
|
||||||
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)
|
||||||
|
|
||||||
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||||
// Reuses the notification service (delivery) and mailer (feedback reply).
|
// Reuses the notification service (delivery) and mailer (feedback reply).
|
||||||
// Gated by RequireAuth + RequireAdmin in the router.
|
// Gated by RequireAuth + RequireAdmin in the router.
|
||||||
adminRepo := repository.NewAdminRepo(s.pg)
|
adminRepo := repository.NewAdminRepo(s.pg)
|
||||||
adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL)
|
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
||||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +451,10 @@ func (s *Server) setupRouter() {
|
|||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
r.Use(chiMiddleware.RequestID)
|
r.Use(chiMiddleware.RequestID)
|
||||||
r.Use(chiMiddleware.RealIP)
|
// Our RealIP (not chi's): a single trusted edge proxy (Dokploy's Traefik)
|
||||||
|
// appends the real client to the right of X-Forwarded-For, so we read the
|
||||||
|
// right-most entry. chi's RealIP trusts the spoofable left-most entry.
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
r.Use(middleware.Logger(s.logger))
|
r.Use(middleware.Logger(s.logger))
|
||||||
r.Use(chiMiddleware.Recoverer)
|
r.Use(chiMiddleware.Recoverer)
|
||||||
r.Use(chiMiddleware.Compress(5))
|
r.Use(chiMiddleware.Compress(5))
|
||||||
@@ -441,12 +488,10 @@ func (s *Server) setupRouter() {
|
|||||||
// Currently unauthenticated by product decision; gate here if that
|
// Currently unauthenticated by product decision; gate here if that
|
||||||
// ever needs to change.
|
// ever needs to change.
|
||||||
if s.statsH != nil {
|
if s.statsH != nil {
|
||||||
|
// Read-only public snapshot. The feedback acknowledge/resolve actions
|
||||||
|
// moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they
|
||||||
|
// must not be exposed on this unauthenticated page.
|
||||||
r.Get("/stats", s.statsH.Render)
|
r.Get("/stats", s.statsH.Render)
|
||||||
// One-way per-row feedback actions — POST so a refresh of /stats
|
|
||||||
// can't replay them. Both live at the same public access level as
|
|
||||||
// /stats itself (gate them together if that ever changes).
|
|
||||||
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
|
|
||||||
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// API v1
|
// API v1
|
||||||
@@ -596,6 +641,9 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/letters", s.discipleH.LetterCounts)
|
r.Get("/letters", s.discipleH.LetterCounts)
|
||||||
r.Get("/suffixes", s.discipleH.SuffixCounts)
|
r.Get("/suffixes", s.discipleH.SuffixCounts)
|
||||||
r.Get("/search", s.discipleH.Search)
|
r.Get("/search", s.discipleH.Search)
|
||||||
|
// Disciples with a platform account (admin-linked) — drives the
|
||||||
|
// highlight in the /disciples browser.
|
||||||
|
r.Get("/linked", s.discipleH.Linked)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Journals
|
// Journals
|
||||||
@@ -628,9 +676,11 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/years", s.transcriptionH.GetYears)
|
r.Get("/years", s.transcriptionH.GetYears)
|
||||||
r.Get("/places", s.transcriptionH.GetPlaces)
|
r.Get("/places", s.transcriptionH.GetPlaces)
|
||||||
r.Get("/types", s.transcriptionH.GetTypeDistribution)
|
r.Get("/types", s.transcriptionH.GetTypeDistribution)
|
||||||
|
r.Get("/books", s.transcriptionH.GetBooks)
|
||||||
r.Get("/year/{year}", s.transcriptionH.GetByYear)
|
r.Get("/year/{year}", s.transcriptionH.GetByYear)
|
||||||
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
||||||
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
||||||
|
r.Get("/book/{book}", s.transcriptionH.GetByBook)
|
||||||
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
||||||
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
||||||
})
|
})
|
||||||
@@ -638,6 +688,9 @@ func (s *Server) setupRouter() {
|
|||||||
// Search (Typesense)
|
// Search (Typesense)
|
||||||
r.Get("/search", s.searchH.Search)
|
r.Get("/search", s.searchH.Search)
|
||||||
|
|
||||||
|
// Vāṇī of the Day — public daily content pick for the home page.
|
||||||
|
r.Get("/vani/today", s.vaniH.Today)
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||||
@@ -703,6 +756,13 @@ func (s *Server) setupRouter() {
|
|||||||
// opaque JSON object.
|
// opaque JSON object.
|
||||||
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||||
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
||||||
|
// Audio player resume points roam with the account too, so a
|
||||||
|
// lecture picks up where you left off on any device. Schema is
|
||||||
|
// owned by the frontend; the server stores opaque key/position
|
||||||
|
// rows and merges newest-wins.
|
||||||
|
r.Get("/me/audio-progress", s.audioH.Get)
|
||||||
|
r.Put("/me/audio-progress", s.audioH.Put)
|
||||||
|
r.Delete("/me/audio-progress", s.audioH.Delete)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -842,6 +902,57 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
|
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat — inbox, threads, messages. Every route requires a session;
|
||||||
|
// membership is re-checked per request in the service, so a
|
||||||
|
// conversation id on its own grants nothing.
|
||||||
|
if s.chatH != nil {
|
||||||
|
r.Route("/chat", func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/conversations", s.chatH.Inbox)
|
||||||
|
r.Post("/conversations/dm", s.chatH.OpenDM)
|
||||||
|
r.Post("/conversations/group", s.chatH.CreateGroup)
|
||||||
|
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
|
||||||
|
r.Post("/link-preview", s.chatH.LinkPreview)
|
||||||
|
r.Get("/settings", s.chatH.Settings)
|
||||||
|
r.Patch("/settings", s.chatH.UpdateSettings)
|
||||||
|
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
|
||||||
|
r.Route("/conversations/{id}", func(r chi.Router) {
|
||||||
|
r.Get("/", s.chatH.Detail)
|
||||||
|
r.Patch("/", s.chatH.UpdateGroup)
|
||||||
|
r.Post("/members", s.chatH.AddMembers)
|
||||||
|
r.Delete("/members/{handle}", s.chatH.RemoveMember)
|
||||||
|
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
|
||||||
|
r.Get("/messages", s.chatH.Messages)
|
||||||
|
r.Post("/messages", s.chatH.Send)
|
||||||
|
r.Post("/read", s.chatH.MarkRead)
|
||||||
|
r.Post("/delivered", s.chatH.MarkDelivered)
|
||||||
|
r.Get("/receipts", s.chatH.Receipts)
|
||||||
|
r.Post("/mute", s.chatH.SetMute)
|
||||||
|
r.Post("/attachments/presign", s.chatH.PresignAttachment)
|
||||||
|
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
|
||||||
|
r.Get("/avatar", s.chatH.GroupAvatar)
|
||||||
|
r.Get("/gallery", s.chatH.Gallery)
|
||||||
|
r.Get("/starred", s.chatH.Starred)
|
||||||
|
r.Post("/messages/{messageID}/star", s.chatH.Star)
|
||||||
|
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
|
||||||
|
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
|
||||||
|
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
|
||||||
|
r.Post("/forward", s.chatH.Forward)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pusher channel authorization — pusher-js posts here (as its
|
||||||
|
// `authEndpoint`) before subscribing to a private-/presence- channel.
|
||||||
|
// Session-authenticated: the signature is only minted for a caller
|
||||||
|
// who has proven who they are and passed the channel's rule.
|
||||||
|
if s.broadcastAuthH != nil {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/broadcasting/auth", s.broadcastAuthH.Handle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Home feed — the global, personalised feed of posts + its "new on
|
// Home feed — the global, personalised feed of posts + its "new on
|
||||||
// the platform" discovery strip. Auth-required (the feed is
|
// the platform" discovery strip. Auth-required (the feed is
|
||||||
// per-viewer: ordering + relationship flags depend on the caller).
|
// per-viewer: ordering + relationship flags depend on the caller).
|
||||||
@@ -859,6 +970,13 @@ func (s *Server) setupRouter() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Caller's pending incoming follow requests (the notifications
|
||||||
|
// "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}.
|
||||||
|
if s.profileH != nil {
|
||||||
|
r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)).
|
||||||
|
Get("/follow-requests", s.profileH.IncomingFollowRequests)
|
||||||
|
}
|
||||||
|
|
||||||
r.Route("/u", func(r chi.Router) {
|
r.Route("/u", func(r chi.Router) {
|
||||||
if s.profileH == nil {
|
if s.profileH == nil {
|
||||||
r.HandleFunc("/*", s.authUnavailable)
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
@@ -920,10 +1038,20 @@ func (s *Server) setupRouter() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
if s.feedbackH == nil {
|
if s.feedbackH == nil {
|
||||||
r.HandleFunc("/feedback", s.authUnavailable)
|
r.HandleFunc("/feedback", s.authUnavailable)
|
||||||
|
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
// Submit — OptionalAuth (anonymous callers allowed).
|
||||||
r.Post("/feedback", s.feedbackH.Submit)
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/feedback", s.feedbackH.Submit)
|
||||||
|
})
|
||||||
|
// My submissions — RequireAuth. Lists the caller's own rows
|
||||||
|
// with status + admin reply for the /settings/feedbacks page.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/feedback/mine", s.feedbackH.Mine)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notifications — the signed-in user's in-app feed + unread badge.
|
// Notifications — the signed-in user's in-app feed + unread badge.
|
||||||
@@ -988,10 +1116,17 @@ func (s *Server) setupRouter() {
|
|||||||
}
|
}
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||||
|
r.Get("/stats", s.adminH.Stats)
|
||||||
r.Get("/users", s.adminH.ListUsers)
|
r.Get("/users", s.adminH.ListUsers)
|
||||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
|
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||||
|
// Direct-disciple linking: mark an account as one of Śrīla
|
||||||
|
// Prabhupāda's initiated disciples (or clear the mark).
|
||||||
|
r.Get("/disciples", s.adminH.ListDisciples)
|
||||||
|
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||||
|
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||||
r.Get("/posts", s.adminH.ListPosts)
|
r.Get("/posts", s.adminH.ListPosts)
|
||||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||||
@@ -1002,8 +1137,12 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/collections", s.adminH.ListCollections)
|
r.Get("/collections", s.adminH.ListCollections)
|
||||||
r.Get("/feedback", s.adminH.ListFeedback)
|
r.Get("/feedback", s.adminH.ListFeedback)
|
||||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||||
|
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||||
|
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||||
r.Get("/reports", s.adminH.ListReports)
|
r.Get("/reports", s.adminH.ListReports)
|
||||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||||
|
r.Get("/short-urls", s.adminH.ListShortURLs)
|
||||||
|
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"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"
|
||||||
@@ -21,19 +22,271 @@ var ErrAdminUserNotFound = errors.New("user not found")
|
|||||||
// existing NotificationService for delivery and the shared mailer for the
|
// existing NotificationService for delivery and the shared mailer for the
|
||||||
// feedback-reply email — no new infrastructure.
|
// feedback-reply email — no new infrastructure.
|
||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
repo *repository.AdminRepo
|
repo *repository.AdminRepo
|
||||||
notif *NotificationService
|
stats *repository.StatsRepo
|
||||||
|
notif *NotificationService
|
||||||
|
feedback *FeedbackService
|
||||||
|
// users / disciples back the direct-disciple linking feature: the link
|
||||||
|
// column lives in Postgres (users) while the disciple record lives in
|
||||||
|
// Mongo (disciples), so the service is where the two stores meet. Both
|
||||||
|
// may be nil in degraded boots (no Mongo) — the link methods guard.
|
||||||
|
users *repository.UserRepo
|
||||||
|
disciples *repository.DiscipleRepo
|
||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
frontendURL string
|
frontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, users *repository.UserRepo, disciples *repository.DiscipleRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||||
return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL}
|
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, users: users, disciples: disciples, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns the system snapshot (counts + 7-day deltas + feedback
|
||||||
|
// breakdown) — the same data the public /stats page renders, as JSON for the
|
||||||
|
// admin dashboard home.
|
||||||
|
func (s *AdminService) Stats(ctx context.Context) (*repository.StatsSnapshot, error) {
|
||||||
|
return s.stats.Snapshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcknowledgeFeedback marks a feedback row acknowledged and emails the author
|
||||||
|
// that we've seen it. Reuses FeedbackService so the email is identical to the
|
||||||
|
// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the
|
||||||
|
// row was already acknowledged.
|
||||||
|
func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error {
|
||||||
|
return s.feedback.Acknowledge(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFeedback marks a feedback row resolved and emails the author a
|
||||||
|
// thank-you. Returns ErrAlreadyResolved when the row was already resolved.
|
||||||
|
func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
|
||||||
|
return s.feedback.Resolve(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
return s.repo.ListUsers(ctx, q, limit, offset)
|
users, total, err := s.repo.ListUsers(ctx, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.hydrateDiscipleNames(ctx, users)
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateDiscipleNames fills DiscipleName on any linked rows in the page with a
|
||||||
|
// single Mongo lookup. Best-effort by design: the panel is far more useful with
|
||||||
|
// an un-named link than with a 500, so a Mongo failure logs and leaves the
|
||||||
|
// names nil (the UI then shows the bare "linked" state).
|
||||||
|
func (s *AdminService) hydrateDiscipleNames(ctx context.Context, users []model.AdminUser) {
|
||||||
|
if s.disciples == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
if u.DiscipleID != nil && *u.DiscipleID != "" {
|
||||||
|
ids = append(ids, *u.DiscipleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
docs, err := s.disciples.FindByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("admin: hydrate disciple names failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byID := make(map[string]string, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
byID[d.ID.Hex()] = strings.TrimSpace(d.InitiatedName + " " + d.Suffix)
|
||||||
|
}
|
||||||
|
for i := range users {
|
||||||
|
if users[i].DiscipleID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := byID[*users[i].DiscipleID]; ok {
|
||||||
|
users[i].DiscipleName = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrDiscipleNotFound is returned by LinkDisciple when the requested disciple
|
||||||
|
// id doesn't resolve to a document in Mongo.
|
||||||
|
var ErrDiscipleNotFound = errors.New("disciple not found")
|
||||||
|
|
||||||
|
// ErrDisciplesUnavailable is returned when the Mongo-backed disciple
|
||||||
|
// collection isn't wired (degraded boot) — linking can't be validated, so we
|
||||||
|
// refuse rather than write an unverified id.
|
||||||
|
var ErrDisciplesUnavailable = errors.New("disciple directory unavailable")
|
||||||
|
|
||||||
|
// SearchDisciples backs the admin panel's disciple picker. An empty query
|
||||||
|
// returns the first page alphabetically so the picker opens with content.
|
||||||
|
// Each option carries the handle of the account already linked to it, letting
|
||||||
|
// the UI disable those rows instead of surfacing a conflict on submit.
|
||||||
|
func (s *AdminService) SearchDisciples(ctx context.Context, q string, limit int) ([]model.AdminDisciple, error) {
|
||||||
|
if s.disciples == nil {
|
||||||
|
return nil, ErrDisciplesUnavailable
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
docs, err := s.disciples.Search(ctx, strings.TrimSpace(q), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which disciples are already claimed, and by whom.
|
||||||
|
claimed := map[string]string{}
|
||||||
|
if s.users != nil {
|
||||||
|
links, err := s.users.ListDiscipleLinks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("admin: load disciple links failed", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
for _, l := range links {
|
||||||
|
claimed[l.DiscipleID] = l.Handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]model.AdminDisciple, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
id := d.ID.Hex()
|
||||||
|
opt := model.AdminDisciple{
|
||||||
|
ID: id,
|
||||||
|
InitiatedName: d.InitiatedName,
|
||||||
|
Suffix: d.Suffix,
|
||||||
|
InitiationDate: d.InitiationDate,
|
||||||
|
InitiationPlace: d.InitiationPlace,
|
||||||
|
}
|
||||||
|
if h, ok := claimed[id]; ok {
|
||||||
|
handle := h
|
||||||
|
opt.LinkedHandle = &handle
|
||||||
|
}
|
||||||
|
out = append(out, opt)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkDisciple marks a user as one of Śrīla Prabhupāda's direct disciples by
|
||||||
|
// pointing them at a disciple record. The id is validated against Mongo first
|
||||||
|
// so a typo can't create a link that renders as an unnamed badge forever.
|
||||||
|
// Returns ErrAdminUserNotFound, ErrDiscipleNotFound, or
|
||||||
|
// repository.ErrDiscipleClaimed.
|
||||||
|
func (s *AdminService) LinkDisciple(ctx context.Context, userID, discipleID string) error {
|
||||||
|
if s.disciples == nil {
|
||||||
|
return ErrDisciplesUnavailable
|
||||||
|
}
|
||||||
|
doc, err := s.disciples.FindByID(ctx, strings.TrimSpace(discipleID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if doc == nil {
|
||||||
|
return ErrDiscipleNotFound
|
||||||
|
}
|
||||||
|
found, previous, err := s.repo.LinkDisciple(ctx, userID, doc.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below is first-link-only. Correcting a mis-link (previous
|
||||||
|
// != nil) must not greet the devotee twice or re-announce them.
|
||||||
|
if previous != nil && *previous != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.users == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
s.logger.Warn("disciple linked but user lookup failed; skipping welcome + announcement",
|
||||||
|
zap.String("user_id", userID), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendDiscipleWelcome(u, strings.TrimSpace(doc.InitiatedName+" "+doc.Suffix))
|
||||||
|
s.announceDiscipleJoined(ctx, u, doc.ID.Hex())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceDiscipleJoined tells the rest of the community that a direct
|
||||||
|
// disciple has joined — in-app for everyone, plus web push for those who've
|
||||||
|
// enabled it. No email: see the deliverability reasoning behind keeping
|
||||||
|
// announcements off the transactional mail domain.
|
||||||
|
//
|
||||||
|
// The DB claim is atomic and synchronous (so a double-click can't double
|
||||||
|
// announce), but the fan-out itself runs detached: pushing to hundreds of
|
||||||
|
// devotees is one external HTTP call each, far too slow to hold the admin's
|
||||||
|
// request open. `context.WithoutCancel` keeps it alive past the response, with
|
||||||
|
// a hard ceiling so a wedged run can't leak forever.
|
||||||
|
func (s *AdminService) announceDiscipleJoined(ctx context.Context, u *model.User, discipleID string) {
|
||||||
|
if s.notif == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.BannedAt != nil {
|
||||||
|
// Don't announce a suspended account to the community.
|
||||||
|
s.logger.Info("disciple linked on a suspended account; skipping announcement",
|
||||||
|
zap.String("user_id", u.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimed, err := s.repo.ClaimDiscipleAnnouncement(ctx, u.ID, discipleID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("disciple announcement claim failed; skipping broadcast",
|
||||||
|
zap.String("user_id", u.ID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
// Already announced previously — nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute)
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
if err := s.notif.BroadcastDiscipleJoined(bg, u); err != nil {
|
||||||
|
s.logger.Error("disciple-joined broadcast failed",
|
||||||
|
zap.String("user_id", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendDiscipleWelcome emails the newly-recognised disciple. Fire-and-forget,
|
||||||
|
// exactly like the feedback emails: the link is already committed, so a mail
|
||||||
|
// failure must not fail the admin's action — it logs and moves on.
|
||||||
|
func (s *AdminService) sendDiscipleWelcome(u *model.User, discipleName string) {
|
||||||
|
if u.Email == nil || strings.TrimSpace(*u.Email) == "" {
|
||||||
|
// OAuth-only account with no address on file — nothing to send.
|
||||||
|
s.logger.Info("disciple linked; no email on file, skipping welcome",
|
||||||
|
zap.String("user_id", u.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := discipleWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": strings.TrimSpace(u.Name),
|
||||||
|
"DiscipleName": discipleName,
|
||||||
|
"ProfileURL": s.frontendURL + "/u/" + u.Handle,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render disciple welcome email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*u.Email),
|
||||||
|
"Welcome, and thank you — Prabhupāda Says", buf.String()); err != nil {
|
||||||
|
s.logger.Warn("disciple welcome email send failed",
|
||||||
|
zap.String("user_id", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkDisciple clears a user's direct-disciple link. Idempotent — clearing an
|
||||||
|
// already-clear link succeeds.
|
||||||
|
func (s *AdminService) UnlinkDisciple(ctx context.Context, userID string) error {
|
||||||
|
ok, err := s.repo.UnlinkDisciple(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||||
@@ -65,9 +318,10 @@ func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body strin
|
|||||||
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
|
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplyFeedback stores the reply (resolving the row) and emails it to the
|
// ReplyFeedback stores the reply and emails it to the submitter when an
|
||||||
// submitter when an address is known. Returns found=false if the id is
|
// address is known. It does not resolve the row (resolve is a separate
|
||||||
// unknown, and emailed=true when a reply email was actually sent.
|
// action). Returns found=false if the id is unknown, and emailed=true when a
|
||||||
|
// reply email was actually sent.
|
||||||
func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) {
|
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)
|
email, name, ok, err := s.repo.SaveReply(ctx, id, message)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
@@ -97,8 +351,8 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
|
|||||||
// ResolveReport stamps a report resolved. found=true when this call resolved a
|
// 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
|
// previously-open report (idempotent: a no-op on an already-resolved/unknown id
|
||||||
// returns false).
|
// returns false).
|
||||||
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) {
|
||||||
return s.repo.ResolveReport(ctx, reportType, id)
|
return s.repo.ResolveReport(ctx, reportType, id, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Moderation actions ──────────────────────────────────────────────────────
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
@@ -133,8 +387,49 @@ func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned
|
|||||||
return s.repo.SetUserBanned(ctx, userID, banned)
|
return s.repo.SetUserBanned(ctx, userID, banned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserLimits writes per-user save-limit overrides (a nil field resets that
|
||||||
|
// limit to the plan default). found=false when the id is unknown.
|
||||||
|
func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||||
|
return s.repo.SetUserLimits(ctx, userID, bookmark, publicCollection, privateCollection)
|
||||||
|
}
|
||||||
|
|
||||||
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||||
// html/template, so admin-entered text can't inject markup.
|
// html/template, so admin-entered text can't inject markup.
|
||||||
|
// discipleWelcomeEmailTpl greets a devotee an admin has just recognised as one
|
||||||
|
// of Śrīla Prabhupāda's own initiated disciples. Same Vaishnava styling and
|
||||||
|
// 17px base as the other author-facing templates.
|
||||||
|
var discipleWelcomeEmailTpl = template.Must(template.New("discipleWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>Please accept our humble obeisances.</p>
|
||||||
|
|
||||||
|
<p>It is our great fortune to welcome you to
|
||||||
|
<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>.
|
||||||
|
We have recognised your account as belonging to
|
||||||
|
{{if .DiscipleName}}<strong>{{.DiscipleName}}</strong>, one of{{else}}one of{{end}}
|
||||||
|
Śrīla Prabhupāda's own initiated disciples.</p>
|
||||||
|
|
||||||
|
<p>Your profile now carries His Divine Grace's picture beside your name, and
|
||||||
|
your name is highlighted in our directory of his disciples — so every devotee
|
||||||
|
who visits knows they are hearing from someone who served Śrīla Prabhupāda
|
||||||
|
personally.</p>
|
||||||
|
|
||||||
|
<p>This whole library exists to preserve and spread Śrīla Prabhupāda's
|
||||||
|
<em>vāṇī</em>. Your presence here — your memories, your realisations, your
|
||||||
|
corrections — is a gift to the devotees who came after. We would be honoured
|
||||||
|
by whatever you care to share.</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your profile</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>If anything about your account needs correcting, simply reply to this
|
||||||
|
email and we will attend to it personally.</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
|
|
||||||
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
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 style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListShortURLs pages the shortened-URL table for the admin panel.
|
||||||
|
func (s *AdminService) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
|
||||||
|
return s.repo.ListShortURLs(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortURLStats computes the today / this-week / this-month / all-time short-URL
|
||||||
|
// summary (and an optional custom range). The fixed windows are UTC-calendar
|
||||||
|
// based: today from 00:00 UTC, week from Monday 00:00 UTC, month from the 1st.
|
||||||
|
func (s *AdminService) ShortURLStats(ctx context.Context, from, to *time.Time) (*model.ShortURLStats, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
// Monday as the first day of the week (Weekday: Sun=0 … Sat=6).
|
||||||
|
weekStart := todayStart.AddDate(0, 0, -((int(now.Weekday()) + 6) % 7))
|
||||||
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
return s.repo.ShortURLStats(ctx, todayStart, weekStart, monthStart, from, to)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Package service — AudioProgressService is a thin passthrough over
|
||||||
|
// AudioProgressRepo. The audio player's resume points carry no server-side
|
||||||
|
// business logic (the frontend owns the schema and the newest-wins merge
|
||||||
|
// is enforced in SQL), so this layer exists only to keep the
|
||||||
|
// handler → service → repository shape consistent with the rest of the API.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioProgressService struct {
|
||||||
|
repo *repository.AudioProgressRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioProgressService(repo *repository.AudioProgressRepo) *AudioProgressService {
|
||||||
|
return &AudioProgressService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioProgressService) List(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||||
|
return s.repo.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioProgressService) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||||
|
return s.repo.Upsert(ctx, userID, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioProgressService) Delete(ctx context.Context, userID, key string) error {
|
||||||
|
return s.repo.Delete(ctx, userID, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message formatting.
|
||||||
|
//
|
||||||
|
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
|
||||||
|
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
|
||||||
|
// of spans describing what to decorate. Two reasons: rendering spans can't
|
||||||
|
// inject HTML no matter what a user types, and every client (web today, native
|
||||||
|
// later) applies them natively instead of parsing a markup dialect.
|
||||||
|
//
|
||||||
|
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
|
||||||
|
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
|
||||||
|
// up if we count the same way it does. This matters constantly here: Devanāgarī
|
||||||
|
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
|
||||||
|
|
||||||
|
// EntityType values understood by the renderer.
|
||||||
|
const (
|
||||||
|
EntityBold = "bold"
|
||||||
|
EntityStrike = "strike"
|
||||||
|
EntityCode = "code"
|
||||||
|
EntityURL = "url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity marks a formatted span of a message body.
|
||||||
|
type Entity struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Offset int `json:"offset"` // in UTF-16 code units
|
||||||
|
Length int `json:"length"` // in UTF-16 code units
|
||||||
|
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
|
||||||
|
// text becomes a usable "https://www.x.org" without the client guessing.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// markers maps the opening/closing rune to the entity it produces. Code is
|
||||||
|
// handled with the same table but its contents are treated as literal.
|
||||||
|
var markers = map[rune]string{
|
||||||
|
'*': EntityBold,
|
||||||
|
'~': EntityStrike,
|
||||||
|
'`': EntityCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
|
||||||
|
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
|
||||||
|
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
|
||||||
|
|
||||||
|
// trailing punctuation that is far more likely to be sentence punctuation than
|
||||||
|
// part of the URL.
|
||||||
|
const urlTrailing = `.,;:!?'"`
|
||||||
|
|
||||||
|
// ParseEntities converts raw composer text into clean body text plus the spans
|
||||||
|
// describing its formatting. Markers are removed from the body — what the
|
||||||
|
// reader copies is the text, not the syntax.
|
||||||
|
func ParseEntities(raw string) (string, []Entity) {
|
||||||
|
var b strings.Builder
|
||||||
|
entities, _ := parseInto(&b, raw, 0)
|
||||||
|
body := b.String()
|
||||||
|
|
||||||
|
// Links are detected last, over the finished body, so offsets need no
|
||||||
|
// adjustment. Code spans are skipped: a URL inside `backticks` is being
|
||||||
|
// shown, not linked.
|
||||||
|
entities = append(entities, detectURLs(body, entities)...)
|
||||||
|
sortEntities(entities)
|
||||||
|
return body, entities
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInto appends the cleaned text of raw to b and returns its entities plus
|
||||||
|
// how many UTF-16 units were written. Returning the written count (rather than
|
||||||
|
// deriving it from the input) keeps offsets correct however many markers a
|
||||||
|
// nested parse strips.
|
||||||
|
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
|
||||||
|
runes := []rune(raw)
|
||||||
|
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
kind, isMarker := markers[r]
|
||||||
|
if !isMarker {
|
||||||
|
b.WriteRune(r)
|
||||||
|
written += utf16Len(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
close := findClose(runes, i)
|
||||||
|
if close < 0 {
|
||||||
|
// An unmatched marker is just a character the user typed.
|
||||||
|
b.WriteRune(r)
|
||||||
|
written += utf16Len(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := string(runes[i+1 : close])
|
||||||
|
start := base + written
|
||||||
|
|
||||||
|
if kind == EntityCode {
|
||||||
|
// Code is literal: markers inside it are content, not syntax.
|
||||||
|
b.WriteString(inner)
|
||||||
|
written += utf16LenString(inner)
|
||||||
|
} else {
|
||||||
|
// Recurse so *bold with `code` inside* works.
|
||||||
|
nested, n := parseInto(b, inner, start)
|
||||||
|
entities = append(entities, nested...)
|
||||||
|
written += n
|
||||||
|
}
|
||||||
|
|
||||||
|
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
|
||||||
|
i = close
|
||||||
|
}
|
||||||
|
return entities, written
|
||||||
|
}
|
||||||
|
|
||||||
|
// findClose returns the index of the marker closing the one at open, or -1.
|
||||||
|
//
|
||||||
|
// The rules mirror what chat users expect: an opener must start a word and be
|
||||||
|
// followed by content, a closer must end one. Without them "2*3*4" and
|
||||||
|
// "a_b_c"-style text would silently turn into formatting.
|
||||||
|
func findClose(runes []rune, open int) int {
|
||||||
|
marker := runes[open]
|
||||||
|
if open > 0 && isWordRune(runes[open-1]) {
|
||||||
|
return -1 // mid-word: not an opener
|
||||||
|
}
|
||||||
|
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
|
||||||
|
return -1 // empty or space-led content
|
||||||
|
}
|
||||||
|
for i := open + 1; i < len(runes); i++ {
|
||||||
|
if runes[i] != marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unicode.IsSpace(runes[i-1]) {
|
||||||
|
continue // closer must follow content
|
||||||
|
}
|
||||||
|
if i+1 < len(runes) && isWordRune(runes[i+1]) {
|
||||||
|
continue // mid-word: not a closer
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectURLs finds links in body, skipping anything already inside a code span.
|
||||||
|
func detectURLs(body string, existing []Entity) []Entity {
|
||||||
|
var out []Entity
|
||||||
|
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
|
||||||
|
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset := utf16LenString(body[:loc[0]])
|
||||||
|
length := utf16LenString(text)
|
||||||
|
if insideCode(existing, offset) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
href := text
|
||||||
|
if !strings.Contains(href, "://") {
|
||||||
|
href = "https://" + href
|
||||||
|
}
|
||||||
|
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func insideCode(entities []Entity, offset int) bool {
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortEntities orders by offset, then by longer span first, so a renderer can
|
||||||
|
// open outer decorations before the ones nested inside them.
|
||||||
|
func sortEntities(entities []Entity) {
|
||||||
|
for i := 1; i < len(entities); i++ {
|
||||||
|
for j := i; j > 0; j-- {
|
||||||
|
a, b := entities[j-1], entities[j]
|
||||||
|
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entities[j-1], entities[j] = b, a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
|
||||||
|
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
|
||||||
|
func utf16Len(r rune) int {
|
||||||
|
if r > 0xFFFF {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16LenString(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
n += utf16Len(r)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
// utf16Slice extracts a span the way a JavaScript client would, so the tests
|
||||||
|
// assert the offsets are usable by the actual renderer rather than merely
|
||||||
|
// self-consistent.
|
||||||
|
func utf16Slice(s string, offset, length int) string {
|
||||||
|
u := utf16.Encode([]rune(s))
|
||||||
|
if offset < 0 || offset+length > len(u) {
|
||||||
|
return "<out of range>"
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(u[offset : offset+length]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEntities_Markers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantBody string
|
||||||
|
wantSpan map[string]string // entity type -> the text it should cover
|
||||||
|
}{
|
||||||
|
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
|
||||||
|
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
|
||||||
|
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
|
||||||
|
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body, entities := ParseEntities(tc.raw)
|
||||||
|
if body != tc.wantBody {
|
||||||
|
t.Fatalf("body = %q, want %q", body, tc.wantBody)
|
||||||
|
}
|
||||||
|
for kind, want := range tc.wantSpan {
|
||||||
|
found := false
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Type == kind {
|
||||||
|
if got := utf16Slice(body, e.Offset, e.Length); got != want {
|
||||||
|
t.Errorf("%s covers %q, want %q", kind, got, want)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no %s entity in %+v", kind, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsets are UTF-16 code units because the client slices JS strings with them.
|
||||||
|
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
|
||||||
|
// rune-based offset would silently mis-highlight in exactly this app's content.
|
||||||
|
func TestParseEntities_UTF16Offsets(t *testing.T) {
|
||||||
|
tests := []struct{ name, raw, want string }{
|
||||||
|
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
|
||||||
|
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
|
||||||
|
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
|
||||||
|
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body, entities := ParseEntities(tc.raw)
|
||||||
|
if len(entities) == 0 {
|
||||||
|
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
|
||||||
|
}
|
||||||
|
e := entities[len(entities)-1]
|
||||||
|
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
|
||||||
|
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text that merely contains marker characters must survive untouched.
|
||||||
|
func TestParseEntities_NotFormatting(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"2*3*4", // mid-word asterisks
|
||||||
|
"a~b~c", // mid-word tildes
|
||||||
|
"* not bold", // space after opener
|
||||||
|
"unmatched *marker", // no closer
|
||||||
|
"**", // empty
|
||||||
|
"5 * 3 = 15", // spaced operators
|
||||||
|
} {
|
||||||
|
body, entities := ParseEntities(raw)
|
||||||
|
if body != raw {
|
||||||
|
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
|
||||||
|
}
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Type != EntityURL {
|
||||||
|
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEntities_Nesting(t *testing.T) {
|
||||||
|
body, entities := ParseEntities("*bold with `code` inside*")
|
||||||
|
const wantBody = "bold with code inside"
|
||||||
|
if body != wantBody {
|
||||||
|
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||||
|
}
|
||||||
|
var bold, code *Entity
|
||||||
|
for i := range entities {
|
||||||
|
switch entities[i].Type {
|
||||||
|
case EntityBold:
|
||||||
|
bold = &entities[i]
|
||||||
|
case EntityCode:
|
||||||
|
code = &entities[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bold == nil || code == nil {
|
||||||
|
t.Fatalf("want both bold and code, got %+v", entities)
|
||||||
|
}
|
||||||
|
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
|
||||||
|
t.Errorf("bold covers %q, want the whole body", got)
|
||||||
|
}
|
||||||
|
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
|
||||||
|
t.Errorf("code covers %q, want %q", got, "code")
|
||||||
|
}
|
||||||
|
// Outer span must sort first so a renderer opens it before the nested one.
|
||||||
|
if entities[0].Type != EntityBold {
|
||||||
|
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEntities_URLs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantText string
|
||||||
|
wantHref string
|
||||||
|
}{
|
||||||
|
{"https", "see https://prabhupadasays.org/bg/2/13 here",
|
||||||
|
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
|
||||||
|
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
|
||||||
|
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
|
||||||
|
{"trailing sentence punctuation excluded", "read https://example.org/a.",
|
||||||
|
"https://example.org/a", "https://example.org/a"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body, entities := ParseEntities(tc.raw)
|
||||||
|
var url *Entity
|
||||||
|
for i := range entities {
|
||||||
|
if entities[i].Type == EntityURL {
|
||||||
|
url = &entities[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url == nil {
|
||||||
|
t.Fatalf("no url entity in %+v", entities)
|
||||||
|
}
|
||||||
|
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
|
||||||
|
t.Errorf("covers %q, want %q", got, tc.wantText)
|
||||||
|
}
|
||||||
|
if url.URL != tc.wantHref {
|
||||||
|
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL inside backticks is being shown, not linked.
|
||||||
|
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
|
||||||
|
_, entities := ParseEntities("run `curl https://example.org` first")
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Type == EntityURL {
|
||||||
|
t.Errorf("linked a URL inside a code span: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
|
||||||
|
// keeps scripture references from turning blue.
|
||||||
|
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
|
||||||
|
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
|
||||||
|
_, entities := ParseEntities(raw)
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Type == EntityURL {
|
||||||
|
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entities is stored as jsonb; empty must round-trip as [] rather than null so
|
||||||
|
// the column default and the parser agree.
|
||||||
|
func TestParseEntities_JSONShape(t *testing.T) {
|
||||||
|
_, entities := ParseEntities("plain text")
|
||||||
|
if entities == nil {
|
||||||
|
entities = []Entity{}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(entities)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(b) != "[]" {
|
||||||
|
t.Errorf("marshalled %s, want []", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,12 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListForUser returns the signed-in user's own submissions (newest
|
||||||
|
// first) with status and any admin reply, for the "My feedback" page.
|
||||||
|
func (s *FeedbackService) ListForUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
|
||||||
|
return s.repo.ListByUser(ctx, userID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// ErrAlreadyResolved is returned by Resolve when the row was already
|
// ErrAlreadyResolved is returned by Resolve when the row was already
|
||||||
// closed (or doesn't exist). The handler maps it to a benign redirect
|
// closed (or doesn't exist). The handler maps it to a benign redirect
|
||||||
// rather than an error — the end state the admin wanted is already true.
|
// rather than an error — the end state the admin wanted is already true.
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const (
|
|||||||
NotifCommentReply = "comment_reply"
|
NotifCommentReply = "comment_reply"
|
||||||
NotifCommentMention = "comment_mention"
|
NotifCommentMention = "comment_mention"
|
||||||
|
|
||||||
|
// NotifDiscipleJoined is broadcast to the whole community when an admin
|
||||||
|
// recognises an account as one of Śrīla Prabhupāda's own initiated
|
||||||
|
// disciples. The actor is the disciple, so it renders and links exactly
|
||||||
|
// like the other actor-driven kinds.
|
||||||
|
NotifDiscipleJoined = "disciple_joined"
|
||||||
|
|
||||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
// 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.
|
||||||
NotifChantReminder = "chant_reminder"
|
NotifChantReminder = "chant_reminder"
|
||||||
@@ -53,6 +59,7 @@ var PrefCategories = map[string][]string{
|
|||||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||||
|
"community": {NotifDiscipleJoined},
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrefChannels is every delivery channel a matrix cell can address.
|
// PrefChannels is every delivery channel a matrix cell can address.
|
||||||
@@ -269,7 +276,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
|||||||
payload["actor_handle"] = actorUser.Handle
|
payload["actor_handle"] = actorUser.Handle
|
||||||
payload["actor_avatar_url"] = actorUser.AvatarURL
|
payload["actor_avatar_url"] = actorUser.AvatarURL
|
||||||
}
|
}
|
||||||
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
|
if err := s.rt.TriggerUser(ctx, authorID, "notification", payload); err != nil {
|
||||||
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
|
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,7 +534,7 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
|
|||||||
payload["actor_handle"] = actor.Handle
|
payload["actor_handle"] = actor.Handle
|
||||||
payload["actor_avatar_url"] = actor.AvatarURL
|
payload["actor_avatar_url"] = actor.AvatarURL
|
||||||
}
|
}
|
||||||
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
|
if err := s.rt.TriggerUser(ctx, recipientID, "notification", payload); err != nil {
|
||||||
s.logger.Warn("realtime notification publish failed",
|
s.logger.Warn("realtime notification publish failed",
|
||||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -543,7 +550,7 @@ func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, ti
|
|||||||
// 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 && !prefOff(off, kind, repository.ChannelInApp) {
|
if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) {
|
||||||
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
|
_ = s.rt.TriggerUser(ctx, userID, "reminder", map[string]any{
|
||||||
"kind": kind, "title": title, "body": body, "url": url,
|
"kind": kind, "title": title, "body": body, "url": url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -625,7 +632,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
|||||||
url := s.frontendURL + "/notifications"
|
url := s.frontendURL + "/notifications"
|
||||||
|
|
||||||
if s.rt != nil {
|
if s.rt != nil {
|
||||||
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
|
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
||||||
"kind": "admin_message",
|
"kind": "admin_message",
|
||||||
"body": body,
|
"body": body,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -672,6 +679,90 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
|
||||||
|
// request per subscription to an external service (FCM/Mozilla/Apple), so a
|
||||||
|
// tight loop over hundreds of devotees would hammer those endpoints and spike
|
||||||
|
// outbound connections. A small gap keeps the broadcast a background trickle.
|
||||||
|
const pushBroadcastDelay = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// BroadcastDiscipleJoined tells the community that `actor` — an account an
|
||||||
|
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
|
||||||
|
// disciples — has joined.
|
||||||
|
//
|
||||||
|
// Two channels, deliberately decoupled by cost:
|
||||||
|
//
|
||||||
|
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
|
||||||
|
// happens inline and the row is durable the moment this returns.
|
||||||
|
// - Push + realtime: one call per recipient against external services, so it
|
||||||
|
// runs paced. The caller is expected to invoke this on a detached,
|
||||||
|
// time-limited context (not the admin's request context, which dies with
|
||||||
|
// the response).
|
||||||
|
//
|
||||||
|
// No email — announcements are in-app/push only by design; see the
|
||||||
|
// deliverability reasoning in the disciple-link notes.
|
||||||
|
//
|
||||||
|
// Errors from the fan-out are logged, not returned: the announcement is
|
||||||
|
// already durably recorded in-app, and a failed push to one devotee must not
|
||||||
|
// abort the rest.
|
||||||
|
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
|
||||||
|
if actor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("disciple-joined broadcast: in-app rows written",
|
||||||
|
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
|
||||||
|
|
||||||
|
// Everything below is best-effort delivery on top of the durable row.
|
||||||
|
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(recipients) == 0 || s.push == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := actorDisplay(actor)
|
||||||
|
payload := PushPayload{
|
||||||
|
Title: "A direct disciple has joined",
|
||||||
|
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
|
||||||
|
URL: s.frontendURL + "/u/" + actor.Handle,
|
||||||
|
Icon: s.iconURL(),
|
||||||
|
Tag: NotifDiscipleJoined,
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
for _, uid := range recipients {
|
||||||
|
// Honour cancellation so a shutdown (or the caller's timeout) stops
|
||||||
|
// the fan-out cleanly rather than running to completion detached.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
|
||||||
|
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.rt != nil {
|
||||||
|
if err := s.rt.TriggerUser(ctx, uid, "notification", map[string]any{
|
||||||
|
"kind": NotifDiscipleJoined,
|
||||||
|
"actor_name": name,
|
||||||
|
"actor_handle": actor.Handle,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Debug("disciple-joined realtime publish failed",
|
||||||
|
zap.String("recipient", uid), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push.SendToUser(ctx, uid, payload)
|
||||||
|
sent++
|
||||||
|
time.Sleep(pushBroadcastDelay)
|
||||||
|
}
|
||||||
|
s.logger.Info("disciple-joined broadcast: push fan-out complete",
|
||||||
|
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// iconURL is the absolute URL of the notification icon (the app's square
|
// 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 {
|
||||||
|
|||||||
+23
-47
@@ -3,19 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plan caps for the social features. Updates here propagate everywhere via
|
// Plan identifiers. The numeric caps live in model.ResolveLimits (so the self
|
||||||
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
// user payload and these enforcers agree), and per-user admin overrides take
|
||||||
|
// precedence over the plan default.
|
||||||
const (
|
const (
|
||||||
PlanFree = "free"
|
PlanFree = "free"
|
||||||
PlanPremium = "premium"
|
PlanPremium = "premium"
|
||||||
|
|
||||||
freeBookmarkLimit = 1
|
|
||||||
premiumBookmarkLimit = 5
|
|
||||||
|
|
||||||
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
|
||||||
// Premium has no collection cap.
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||||
@@ -38,50 +35,29 @@ func (e *PlanLimitError) Error() string {
|
|||||||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||||
|
|
||||||
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||||
// bookmark, or a PlanLimitError otherwise.
|
// bookmark, or a PlanLimitError otherwise. The cap is the user's effective
|
||||||
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
// bookmark limit (plan default, or an admin override). A negative cap means
|
||||||
limit := bookmarkLimitFor(plan)
|
// unlimited.
|
||||||
if currentCount >= limit {
|
func EnforceBookmarkLimit(u *model.User, currentCount int) error {
|
||||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
limit := u.EffectiveLimits().Bookmarks
|
||||||
|
if limit >= 0 && currentCount >= limit {
|
||||||
|
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: u.Plan}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
// EnforceCollectionLimit returns nil if the user is allowed to create one more
|
||||||
// more collection of the given visibility.
|
// collection of the given visibility. The cap is the user's effective
|
||||||
//
|
// public/private collection limit (plan default, or an admin override); a
|
||||||
// `isPublic=true` → checks against public-collection cap
|
// negative cap means unlimited.
|
||||||
// `isPublic=false` → checks against private-collection cap
|
func EnforceCollectionLimit(u *model.User, isPublic bool, currentCount int) error {
|
||||||
// Premium users have no cap → always nil.
|
l := u.EffectiveLimits()
|
||||||
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
limit, resource := l.PrivateCollections, "private collections"
|
||||||
if plan == PlanPremium {
|
if isPublic {
|
||||||
return nil
|
limit, resource = l.PublicCollections, "public collections"
|
||||||
}
|
}
|
||||||
limit := freeCollectionPerVisibilityLimit
|
if limit >= 0 && currentCount >= limit {
|
||||||
if currentCount >= limit {
|
return &PlanLimitError{Resource: resource, Limit: limit, Plan: u.Plan}
|
||||||
resource := "private collections"
|
|
||||||
if isPublic {
|
|
||||||
resource = "public collections"
|
|
||||||
}
|
|
||||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bookmarkLimitFor(plan string) int {
|
|
||||||
if plan == PlanPremium {
|
|
||||||
return premiumBookmarkLimit
|
|
||||||
}
|
|
||||||
return freeBookmarkLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
|
||||||
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
|
||||||
|
|
||||||
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
|
||||||
func CollectionLimitFor(plan string) int {
|
|
||||||
if plan == PlanPremium {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return freeCollectionPerVisibilityLimit
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,15 +51,17 @@ const (
|
|||||||
type ReminderService struct {
|
type ReminderService struct {
|
||||||
repo *repository.ReminderRepo
|
repo *repository.ReminderRepo
|
||||||
prefs *repository.NotificationPrefsRepo
|
prefs *repository.NotificationPrefsRepo
|
||||||
|
users *repository.UserRepo
|
||||||
notif *NotificationService
|
notif *NotificationService
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
sadhnaURL string
|
sadhnaURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
||||||
return &ReminderService{
|
return &ReminderService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
prefs: prefs,
|
prefs: prefs,
|
||||||
|
users: users,
|
||||||
notif: notif,
|
notif: notif,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
|
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
|
||||||
@@ -116,7 +118,21 @@ func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) er
|
|||||||
if _, err := time.LoadLocation(tz); err != nil {
|
if _, err := time.LoadLocation(tz); err != nil {
|
||||||
return ErrInvalidTimezone
|
return ErrInvalidTimezone
|
||||||
}
|
}
|
||||||
return s.repo.SetTimezone(ctx, userID, tz)
|
if err := s.repo.SetTimezone(ctx, userID, tz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Best-effort: backfill the account country from the timezone when it's
|
||||||
|
// still unset (geo-IP at login is the primary source; SetCountryIfEmpty
|
||||||
|
// never overwrites it). A no-op when the zone isn't in the table.
|
||||||
|
if s.users != nil {
|
||||||
|
if cc := tzCountry[tz]; cc != "" {
|
||||||
|
if err := s.users.SetCountryIfEmpty(ctx, userID, cc); err != nil {
|
||||||
|
s.logger.Warn("backfill country from timezone failed",
|
||||||
|
zap.String("user", userID), zap.String("country", cc), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOnce evaluates every due candidate against `now` and fires the reminders
|
// RunOnce evaluates every due candidate against `now` and fires the reminders
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ type SocialGraphService struct {
|
|||||||
reports *repository.ReportRepo
|
reports *repository.ReportRepo
|
||||||
posts *repository.PostRepo
|
posts *repository.PostRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
notif *NotificationService
|
// chat answers "may the viewer open a DM with this profile?" — the same
|
||||||
mail mailer.Mailer
|
// rule OpenDM enforces, so the button and the action can't disagree.
|
||||||
admins []string
|
chat *repository.ChatRepo
|
||||||
logger *zap.Logger
|
notif *NotificationService
|
||||||
|
mail mailer.Mailer
|
||||||
|
admins []string
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSocialGraphService(
|
func NewSocialGraphService(
|
||||||
@@ -83,6 +86,7 @@ func NewSocialGraphService(
|
|||||||
reports *repository.ReportRepo,
|
reports *repository.ReportRepo,
|
||||||
posts *repository.PostRepo,
|
posts *repository.PostRepo,
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
|
chat *repository.ChatRepo,
|
||||||
notif *NotificationService,
|
notif *NotificationService,
|
||||||
mail mailer.Mailer,
|
mail mailer.Mailer,
|
||||||
admins []string,
|
admins []string,
|
||||||
@@ -90,7 +94,8 @@ func NewSocialGraphService(
|
|||||||
) *SocialGraphService {
|
) *SocialGraphService {
|
||||||
return &SocialGraphService{
|
return &SocialGraphService{
|
||||||
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
||||||
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
|
users: users, chat: chat, notif: notif, mail: mail, admins: admins,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +299,33 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
|
|||||||
}
|
}
|
||||||
out.Blocked = viewerBlocked
|
out.Blocked = viewerBlocked
|
||||||
out.BlockedBy = blockedByTarget
|
out.BlockedBy = blockedByTarget
|
||||||
|
|
||||||
|
// A block closes messaging in either direction; otherwise the target's own
|
||||||
|
// "who can message you" rule decides. Asking the chat repository — rather
|
||||||
|
// than reimplementing the rule here — is what keeps the offered button and
|
||||||
|
// the eventual OpenDM in agreement.
|
||||||
|
switch {
|
||||||
|
case viewerID == "" || viewerID == targetID:
|
||||||
|
out.CanMessage = false
|
||||||
|
case viewerBlocked || blockedByTarget:
|
||||||
|
out.CanMessage = false
|
||||||
|
out.MessageRestriction = "blocked"
|
||||||
|
case s.chat == nil:
|
||||||
|
out.CanMessage = true
|
||||||
|
default:
|
||||||
|
allowed, err := s.chat.CanStartConversation(ctx, viewerID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.CanMessage = allowed
|
||||||
|
if !allowed {
|
||||||
|
settings, err := s.chat.Settings(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.MessageRestriction = settings.WhoCanMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +386,13 @@ func (s *SocialGraphService) Following(ctx context.Context, handle, q string, li
|
|||||||
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
|
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IncomingRequests returns the pending follow requests addressed to userID
|
||||||
|
// (the caller) — the "Friend Requests" tab. Caller-scoped, so it takes the id
|
||||||
|
// directly rather than a handle.
|
||||||
|
func (s *SocialGraphService) IncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
|
return s.follows.ListIncomingRequests(ctx, userID, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
// ProfileExtras is the read used by the public-profile endpoint: the
|
// ProfileExtras is the read used by the public-profile endpoint: the
|
||||||
// target's follower/following counts plus, when a non-owner viewer is
|
// target's follower/following counts plus, when a non-owner viewer is
|
||||||
// signed in, their relationship to the target. rel is nil for anonymous
|
// signed in, their relationship to the target. rel is nil for anonymous
|
||||||
|
|||||||
@@ -444,6 +444,33 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
|||||||
return posts, total, nil
|
return posts, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListMineKeyset is the owner's scroll feed via keyset pagination (posts older
|
||||||
|
// than the cursor, newest-first). Same hydration as ListMine; no total.
|
||||||
|
func (s *PostService) ListMineKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
posts, err := s.posts.ListByUserKeyset(ctx, userID, beforeAt, beforeID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if posts[i].ResharedFromPostID != nil {
|
||||||
|
posts[i].ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
||||||
// like ListMine so the cards render fully and the menu offers Unhide.
|
// 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) {
|
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
@@ -511,7 +538,7 @@ func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
if err := EnforceBookmarkLimit(u, current); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +602,7 @@ func (s *CollectionService) Create(ctx context.Context, userID, title string, de
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
if err := EnforceCollectionLimit(u, isPublic, current); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||||||
@@ -601,7 +628,7 @@ func (s *CollectionService) Update(ctx context.Context, id, userID string, title
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
if err := EnforceCollectionLimit(u, *isPublic, current); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// Code generated from the IANA tz database (zone.tab). DO NOT EDIT by hand.
|
||||||
|
// Maps a canonical IANA timezone to its ISO-3166-1 alpha-2 country code.
|
||||||
|
package service
|
||||||
|
|
||||||
|
var tzCountry = map[string]string{
|
||||||
|
"Africa/Abidjan": "CI",
|
||||||
|
"Africa/Accra": "GH",
|
||||||
|
"Africa/Addis_Ababa": "ET",
|
||||||
|
"Africa/Algiers": "DZ",
|
||||||
|
"Africa/Asmara": "ER",
|
||||||
|
"Africa/Bamako": "ML",
|
||||||
|
"Africa/Bangui": "CF",
|
||||||
|
"Africa/Banjul": "GM",
|
||||||
|
"Africa/Bissau": "GW",
|
||||||
|
"Africa/Blantyre": "MW",
|
||||||
|
"Africa/Brazzaville": "CG",
|
||||||
|
"Africa/Bujumbura": "BI",
|
||||||
|
"Africa/Cairo": "EG",
|
||||||
|
"Africa/Casablanca": "MA",
|
||||||
|
"Africa/Ceuta": "ES",
|
||||||
|
"Africa/Conakry": "GN",
|
||||||
|
"Africa/Dakar": "SN",
|
||||||
|
"Africa/Dar_es_Salaam": "TZ",
|
||||||
|
"Africa/Djibouti": "DJ",
|
||||||
|
"Africa/Douala": "CM",
|
||||||
|
"Africa/El_Aaiun": "EH",
|
||||||
|
"Africa/Freetown": "SL",
|
||||||
|
"Africa/Gaborone": "BW",
|
||||||
|
"Africa/Harare": "ZW",
|
||||||
|
"Africa/Johannesburg": "ZA",
|
||||||
|
"Africa/Juba": "SS",
|
||||||
|
"Africa/Kampala": "UG",
|
||||||
|
"Africa/Khartoum": "SD",
|
||||||
|
"Africa/Kigali": "RW",
|
||||||
|
"Africa/Kinshasa": "CD",
|
||||||
|
"Africa/Lagos": "NG",
|
||||||
|
"Africa/Libreville": "GA",
|
||||||
|
"Africa/Lome": "TG",
|
||||||
|
"Africa/Luanda": "AO",
|
||||||
|
"Africa/Lubumbashi": "CD",
|
||||||
|
"Africa/Lusaka": "ZM",
|
||||||
|
"Africa/Malabo": "GQ",
|
||||||
|
"Africa/Maputo": "MZ",
|
||||||
|
"Africa/Maseru": "LS",
|
||||||
|
"Africa/Mbabane": "SZ",
|
||||||
|
"Africa/Mogadishu": "SO",
|
||||||
|
"Africa/Monrovia": "LR",
|
||||||
|
"Africa/Nairobi": "KE",
|
||||||
|
"Africa/Ndjamena": "TD",
|
||||||
|
"Africa/Niamey": "NE",
|
||||||
|
"Africa/Nouakchott": "MR",
|
||||||
|
"Africa/Ouagadougou": "BF",
|
||||||
|
"Africa/Porto-Novo": "BJ",
|
||||||
|
"Africa/Sao_Tome": "ST",
|
||||||
|
"Africa/Tripoli": "LY",
|
||||||
|
"Africa/Tunis": "TN",
|
||||||
|
"Africa/Windhoek": "NA",
|
||||||
|
"America/Adak": "US",
|
||||||
|
"America/Anchorage": "US",
|
||||||
|
"America/Anguilla": "AI",
|
||||||
|
"America/Antigua": "AG",
|
||||||
|
"America/Araguaina": "BR",
|
||||||
|
"America/Argentina/Buenos_Aires": "AR",
|
||||||
|
"America/Argentina/Catamarca": "AR",
|
||||||
|
"America/Argentina/Cordoba": "AR",
|
||||||
|
"America/Argentina/Jujuy": "AR",
|
||||||
|
"America/Argentina/La_Rioja": "AR",
|
||||||
|
"America/Argentina/Mendoza": "AR",
|
||||||
|
"America/Argentina/Rio_Gallegos": "AR",
|
||||||
|
"America/Argentina/Salta": "AR",
|
||||||
|
"America/Argentina/San_Juan": "AR",
|
||||||
|
"America/Argentina/San_Luis": "AR",
|
||||||
|
"America/Argentina/Tucuman": "AR",
|
||||||
|
"America/Argentina/Ushuaia": "AR",
|
||||||
|
"America/Aruba": "AW",
|
||||||
|
"America/Asuncion": "PY",
|
||||||
|
"America/Atikokan": "CA",
|
||||||
|
"America/Bahia": "BR",
|
||||||
|
"America/Bahia_Banderas": "MX",
|
||||||
|
"America/Barbados": "BB",
|
||||||
|
"America/Belem": "BR",
|
||||||
|
"America/Belize": "BZ",
|
||||||
|
"America/Blanc-Sablon": "CA",
|
||||||
|
"America/Boa_Vista": "BR",
|
||||||
|
"America/Bogota": "CO",
|
||||||
|
"America/Boise": "US",
|
||||||
|
"America/Cambridge_Bay": "CA",
|
||||||
|
"America/Campo_Grande": "BR",
|
||||||
|
"America/Cancun": "MX",
|
||||||
|
"America/Caracas": "VE",
|
||||||
|
"America/Cayenne": "GF",
|
||||||
|
"America/Cayman": "KY",
|
||||||
|
"America/Chicago": "US",
|
||||||
|
"America/Chihuahua": "MX",
|
||||||
|
"America/Ciudad_Juarez": "MX",
|
||||||
|
"America/Costa_Rica": "CR",
|
||||||
|
"America/Coyhaique": "CL",
|
||||||
|
"America/Creston": "CA",
|
||||||
|
"America/Cuiaba": "BR",
|
||||||
|
"America/Curacao": "CW",
|
||||||
|
"America/Danmarkshavn": "GL",
|
||||||
|
"America/Dawson": "CA",
|
||||||
|
"America/Dawson_Creek": "CA",
|
||||||
|
"America/Denver": "US",
|
||||||
|
"America/Detroit": "US",
|
||||||
|
"America/Dominica": "DM",
|
||||||
|
"America/Edmonton": "CA",
|
||||||
|
"America/Eirunepe": "BR",
|
||||||
|
"America/El_Salvador": "SV",
|
||||||
|
"America/Fort_Nelson": "CA",
|
||||||
|
"America/Fortaleza": "BR",
|
||||||
|
"America/Glace_Bay": "CA",
|
||||||
|
"America/Goose_Bay": "CA",
|
||||||
|
"America/Grand_Turk": "TC",
|
||||||
|
"America/Grenada": "GD",
|
||||||
|
"America/Guadeloupe": "GP",
|
||||||
|
"America/Guatemala": "GT",
|
||||||
|
"America/Guayaquil": "EC",
|
||||||
|
"America/Guyana": "GY",
|
||||||
|
"America/Halifax": "CA",
|
||||||
|
"America/Havana": "CU",
|
||||||
|
"America/Hermosillo": "MX",
|
||||||
|
"America/Indiana/Indianapolis": "US",
|
||||||
|
"America/Indiana/Knox": "US",
|
||||||
|
"America/Indiana/Marengo": "US",
|
||||||
|
"America/Indiana/Petersburg": "US",
|
||||||
|
"America/Indiana/Tell_City": "US",
|
||||||
|
"America/Indiana/Vevay": "US",
|
||||||
|
"America/Indiana/Vincennes": "US",
|
||||||
|
"America/Indiana/Winamac": "US",
|
||||||
|
"America/Inuvik": "CA",
|
||||||
|
"America/Iqaluit": "CA",
|
||||||
|
"America/Jamaica": "JM",
|
||||||
|
"America/Juneau": "US",
|
||||||
|
"America/Kentucky/Louisville": "US",
|
||||||
|
"America/Kentucky/Monticello": "US",
|
||||||
|
"America/Kralendijk": "BQ",
|
||||||
|
"America/La_Paz": "BO",
|
||||||
|
"America/Lima": "PE",
|
||||||
|
"America/Los_Angeles": "US",
|
||||||
|
"America/Lower_Princes": "SX",
|
||||||
|
"America/Maceio": "BR",
|
||||||
|
"America/Managua": "NI",
|
||||||
|
"America/Manaus": "BR",
|
||||||
|
"America/Marigot": "MF",
|
||||||
|
"America/Martinique": "MQ",
|
||||||
|
"America/Matamoros": "MX",
|
||||||
|
"America/Mazatlan": "MX",
|
||||||
|
"America/Menominee": "US",
|
||||||
|
"America/Merida": "MX",
|
||||||
|
"America/Metlakatla": "US",
|
||||||
|
"America/Mexico_City": "MX",
|
||||||
|
"America/Miquelon": "PM",
|
||||||
|
"America/Moncton": "CA",
|
||||||
|
"America/Monterrey": "MX",
|
||||||
|
"America/Montevideo": "UY",
|
||||||
|
"America/Montserrat": "MS",
|
||||||
|
"America/Nassau": "BS",
|
||||||
|
"America/New_York": "US",
|
||||||
|
"America/Nome": "US",
|
||||||
|
"America/Noronha": "BR",
|
||||||
|
"America/North_Dakota/Beulah": "US",
|
||||||
|
"America/North_Dakota/Center": "US",
|
||||||
|
"America/North_Dakota/New_Salem": "US",
|
||||||
|
"America/Nuuk": "GL",
|
||||||
|
"America/Ojinaga": "MX",
|
||||||
|
"America/Panama": "PA",
|
||||||
|
"America/Paramaribo": "SR",
|
||||||
|
"America/Phoenix": "US",
|
||||||
|
"America/Port-au-Prince": "HT",
|
||||||
|
"America/Port_of_Spain": "TT",
|
||||||
|
"America/Porto_Velho": "BR",
|
||||||
|
"America/Puerto_Rico": "PR",
|
||||||
|
"America/Punta_Arenas": "CL",
|
||||||
|
"America/Rankin_Inlet": "CA",
|
||||||
|
"America/Recife": "BR",
|
||||||
|
"America/Regina": "CA",
|
||||||
|
"America/Resolute": "CA",
|
||||||
|
"America/Rio_Branco": "BR",
|
||||||
|
"America/Santarem": "BR",
|
||||||
|
"America/Santiago": "CL",
|
||||||
|
"America/Santo_Domingo": "DO",
|
||||||
|
"America/Sao_Paulo": "BR",
|
||||||
|
"America/Scoresbysund": "GL",
|
||||||
|
"America/Sitka": "US",
|
||||||
|
"America/St_Barthelemy": "BL",
|
||||||
|
"America/St_Johns": "CA",
|
||||||
|
"America/St_Kitts": "KN",
|
||||||
|
"America/St_Lucia": "LC",
|
||||||
|
"America/St_Thomas": "VI",
|
||||||
|
"America/St_Vincent": "VC",
|
||||||
|
"America/Swift_Current": "CA",
|
||||||
|
"America/Tegucigalpa": "HN",
|
||||||
|
"America/Thule": "GL",
|
||||||
|
"America/Tijuana": "MX",
|
||||||
|
"America/Toronto": "CA",
|
||||||
|
"America/Tortola": "VG",
|
||||||
|
"America/Vancouver": "CA",
|
||||||
|
"America/Whitehorse": "CA",
|
||||||
|
"America/Winnipeg": "CA",
|
||||||
|
"America/Yakutat": "US",
|
||||||
|
"Antarctica/Casey": "AQ",
|
||||||
|
"Antarctica/Davis": "AQ",
|
||||||
|
"Antarctica/DumontDUrville": "AQ",
|
||||||
|
"Antarctica/Macquarie": "AU",
|
||||||
|
"Antarctica/Mawson": "AQ",
|
||||||
|
"Antarctica/McMurdo": "AQ",
|
||||||
|
"Antarctica/Palmer": "AQ",
|
||||||
|
"Antarctica/Rothera": "AQ",
|
||||||
|
"Antarctica/Syowa": "AQ",
|
||||||
|
"Antarctica/Troll": "AQ",
|
||||||
|
"Antarctica/Vostok": "AQ",
|
||||||
|
"Arctic/Longyearbyen": "SJ",
|
||||||
|
"Asia/Aden": "YE",
|
||||||
|
"Asia/Almaty": "KZ",
|
||||||
|
"Asia/Amman": "JO",
|
||||||
|
"Asia/Anadyr": "RU",
|
||||||
|
"Asia/Aqtau": "KZ",
|
||||||
|
"Asia/Aqtobe": "KZ",
|
||||||
|
"Asia/Ashgabat": "TM",
|
||||||
|
"Asia/Atyrau": "KZ",
|
||||||
|
"Asia/Baghdad": "IQ",
|
||||||
|
"Asia/Bahrain": "BH",
|
||||||
|
"Asia/Baku": "AZ",
|
||||||
|
"Asia/Bangkok": "TH",
|
||||||
|
"Asia/Barnaul": "RU",
|
||||||
|
"Asia/Beirut": "LB",
|
||||||
|
"Asia/Bishkek": "KG",
|
||||||
|
"Asia/Brunei": "BN",
|
||||||
|
"Asia/Chita": "RU",
|
||||||
|
"Asia/Colombo": "LK",
|
||||||
|
"Asia/Damascus": "SY",
|
||||||
|
"Asia/Dhaka": "BD",
|
||||||
|
"Asia/Dili": "TL",
|
||||||
|
"Asia/Dubai": "AE",
|
||||||
|
"Asia/Dushanbe": "TJ",
|
||||||
|
"Asia/Famagusta": "CY",
|
||||||
|
"Asia/Gaza": "PS",
|
||||||
|
"Asia/Hebron": "PS",
|
||||||
|
"Asia/Ho_Chi_Minh": "VN",
|
||||||
|
"Asia/Hong_Kong": "HK",
|
||||||
|
"Asia/Hovd": "MN",
|
||||||
|
"Asia/Irkutsk": "RU",
|
||||||
|
"Asia/Jakarta": "ID",
|
||||||
|
"Asia/Jayapura": "ID",
|
||||||
|
"Asia/Jerusalem": "IL",
|
||||||
|
"Asia/Kabul": "AF",
|
||||||
|
"Asia/Kamchatka": "RU",
|
||||||
|
"Asia/Karachi": "PK",
|
||||||
|
"Asia/Kathmandu": "NP",
|
||||||
|
"Asia/Khandyga": "RU",
|
||||||
|
"Asia/Kolkata": "IN",
|
||||||
|
"Asia/Krasnoyarsk": "RU",
|
||||||
|
"Asia/Kuala_Lumpur": "MY",
|
||||||
|
"Asia/Kuching": "MY",
|
||||||
|
"Asia/Kuwait": "KW",
|
||||||
|
"Asia/Macau": "MO",
|
||||||
|
"Asia/Magadan": "RU",
|
||||||
|
"Asia/Makassar": "ID",
|
||||||
|
"Asia/Manila": "PH",
|
||||||
|
"Asia/Muscat": "OM",
|
||||||
|
"Asia/Nicosia": "CY",
|
||||||
|
"Asia/Novokuznetsk": "RU",
|
||||||
|
"Asia/Novosibirsk": "RU",
|
||||||
|
"Asia/Omsk": "RU",
|
||||||
|
"Asia/Oral": "KZ",
|
||||||
|
"Asia/Phnom_Penh": "KH",
|
||||||
|
"Asia/Pontianak": "ID",
|
||||||
|
"Asia/Pyongyang": "KP",
|
||||||
|
"Asia/Qatar": "QA",
|
||||||
|
"Asia/Qostanay": "KZ",
|
||||||
|
"Asia/Qyzylorda": "KZ",
|
||||||
|
"Asia/Riyadh": "SA",
|
||||||
|
"Asia/Sakhalin": "RU",
|
||||||
|
"Asia/Samarkand": "UZ",
|
||||||
|
"Asia/Seoul": "KR",
|
||||||
|
"Asia/Shanghai": "CN",
|
||||||
|
"Asia/Singapore": "SG",
|
||||||
|
"Asia/Srednekolymsk": "RU",
|
||||||
|
"Asia/Taipei": "TW",
|
||||||
|
"Asia/Tashkent": "UZ",
|
||||||
|
"Asia/Tbilisi": "GE",
|
||||||
|
"Asia/Tehran": "IR",
|
||||||
|
"Asia/Thimphu": "BT",
|
||||||
|
"Asia/Tokyo": "JP",
|
||||||
|
"Asia/Tomsk": "RU",
|
||||||
|
"Asia/Ulaanbaatar": "MN",
|
||||||
|
"Asia/Urumqi": "CN",
|
||||||
|
"Asia/Ust-Nera": "RU",
|
||||||
|
"Asia/Vientiane": "LA",
|
||||||
|
"Asia/Vladivostok": "RU",
|
||||||
|
"Asia/Yakutsk": "RU",
|
||||||
|
"Asia/Yangon": "MM",
|
||||||
|
"Asia/Yekaterinburg": "RU",
|
||||||
|
"Asia/Yerevan": "AM",
|
||||||
|
"Atlantic/Azores": "PT",
|
||||||
|
"Atlantic/Bermuda": "BM",
|
||||||
|
"Atlantic/Canary": "ES",
|
||||||
|
"Atlantic/Cape_Verde": "CV",
|
||||||
|
"Atlantic/Faroe": "FO",
|
||||||
|
"Atlantic/Madeira": "PT",
|
||||||
|
"Atlantic/Reykjavik": "IS",
|
||||||
|
"Atlantic/South_Georgia": "GS",
|
||||||
|
"Atlantic/St_Helena": "SH",
|
||||||
|
"Atlantic/Stanley": "FK",
|
||||||
|
"Australia/Adelaide": "AU",
|
||||||
|
"Australia/Brisbane": "AU",
|
||||||
|
"Australia/Broken_Hill": "AU",
|
||||||
|
"Australia/Darwin": "AU",
|
||||||
|
"Australia/Eucla": "AU",
|
||||||
|
"Australia/Hobart": "AU",
|
||||||
|
"Australia/Lindeman": "AU",
|
||||||
|
"Australia/Lord_Howe": "AU",
|
||||||
|
"Australia/Melbourne": "AU",
|
||||||
|
"Australia/Perth": "AU",
|
||||||
|
"Australia/Sydney": "AU",
|
||||||
|
"Europe/Amsterdam": "NL",
|
||||||
|
"Europe/Andorra": "AD",
|
||||||
|
"Europe/Astrakhan": "RU",
|
||||||
|
"Europe/Athens": "GR",
|
||||||
|
"Europe/Belgrade": "RS",
|
||||||
|
"Europe/Berlin": "DE",
|
||||||
|
"Europe/Bratislava": "SK",
|
||||||
|
"Europe/Brussels": "BE",
|
||||||
|
"Europe/Bucharest": "RO",
|
||||||
|
"Europe/Budapest": "HU",
|
||||||
|
"Europe/Busingen": "DE",
|
||||||
|
"Europe/Chisinau": "MD",
|
||||||
|
"Europe/Copenhagen": "DK",
|
||||||
|
"Europe/Dublin": "IE",
|
||||||
|
"Europe/Gibraltar": "GI",
|
||||||
|
"Europe/Guernsey": "GG",
|
||||||
|
"Europe/Helsinki": "FI",
|
||||||
|
"Europe/Isle_of_Man": "IM",
|
||||||
|
"Europe/Istanbul": "TR",
|
||||||
|
"Europe/Jersey": "JE",
|
||||||
|
"Europe/Kaliningrad": "RU",
|
||||||
|
"Europe/Kirov": "RU",
|
||||||
|
"Europe/Kyiv": "UA",
|
||||||
|
"Europe/Lisbon": "PT",
|
||||||
|
"Europe/Ljubljana": "SI",
|
||||||
|
"Europe/London": "GB",
|
||||||
|
"Europe/Luxembourg": "LU",
|
||||||
|
"Europe/Madrid": "ES",
|
||||||
|
"Europe/Malta": "MT",
|
||||||
|
"Europe/Mariehamn": "AX",
|
||||||
|
"Europe/Minsk": "BY",
|
||||||
|
"Europe/Monaco": "MC",
|
||||||
|
"Europe/Moscow": "RU",
|
||||||
|
"Europe/Oslo": "NO",
|
||||||
|
"Europe/Paris": "FR",
|
||||||
|
"Europe/Podgorica": "ME",
|
||||||
|
"Europe/Prague": "CZ",
|
||||||
|
"Europe/Riga": "LV",
|
||||||
|
"Europe/Rome": "IT",
|
||||||
|
"Europe/Samara": "RU",
|
||||||
|
"Europe/San_Marino": "SM",
|
||||||
|
"Europe/Sarajevo": "BA",
|
||||||
|
"Europe/Saratov": "RU",
|
||||||
|
"Europe/Simferopol": "UA",
|
||||||
|
"Europe/Skopje": "MK",
|
||||||
|
"Europe/Sofia": "BG",
|
||||||
|
"Europe/Stockholm": "SE",
|
||||||
|
"Europe/Tallinn": "EE",
|
||||||
|
"Europe/Tirane": "AL",
|
||||||
|
"Europe/Ulyanovsk": "RU",
|
||||||
|
"Europe/Vaduz": "LI",
|
||||||
|
"Europe/Vatican": "VA",
|
||||||
|
"Europe/Vienna": "AT",
|
||||||
|
"Europe/Vilnius": "LT",
|
||||||
|
"Europe/Volgograd": "RU",
|
||||||
|
"Europe/Warsaw": "PL",
|
||||||
|
"Europe/Zagreb": "HR",
|
||||||
|
"Europe/Zurich": "CH",
|
||||||
|
"Indian/Antananarivo": "MG",
|
||||||
|
"Indian/Chagos": "IO",
|
||||||
|
"Indian/Christmas": "CX",
|
||||||
|
"Indian/Cocos": "CC",
|
||||||
|
"Indian/Comoro": "KM",
|
||||||
|
"Indian/Kerguelen": "TF",
|
||||||
|
"Indian/Mahe": "SC",
|
||||||
|
"Indian/Maldives": "MV",
|
||||||
|
"Indian/Mauritius": "MU",
|
||||||
|
"Indian/Mayotte": "YT",
|
||||||
|
"Indian/Reunion": "RE",
|
||||||
|
"Pacific/Apia": "WS",
|
||||||
|
"Pacific/Auckland": "NZ",
|
||||||
|
"Pacific/Bougainville": "PG",
|
||||||
|
"Pacific/Chatham": "NZ",
|
||||||
|
"Pacific/Chuuk": "FM",
|
||||||
|
"Pacific/Easter": "CL",
|
||||||
|
"Pacific/Efate": "VU",
|
||||||
|
"Pacific/Fakaofo": "TK",
|
||||||
|
"Pacific/Fiji": "FJ",
|
||||||
|
"Pacific/Funafuti": "TV",
|
||||||
|
"Pacific/Galapagos": "EC",
|
||||||
|
"Pacific/Gambier": "PF",
|
||||||
|
"Pacific/Guadalcanal": "SB",
|
||||||
|
"Pacific/Guam": "GU",
|
||||||
|
"Pacific/Honolulu": "US",
|
||||||
|
"Pacific/Kanton": "KI",
|
||||||
|
"Pacific/Kiritimati": "KI",
|
||||||
|
"Pacific/Kosrae": "FM",
|
||||||
|
"Pacific/Kwajalein": "MH",
|
||||||
|
"Pacific/Majuro": "MH",
|
||||||
|
"Pacific/Marquesas": "PF",
|
||||||
|
"Pacific/Midway": "UM",
|
||||||
|
"Pacific/Nauru": "NR",
|
||||||
|
"Pacific/Niue": "NU",
|
||||||
|
"Pacific/Norfolk": "NF",
|
||||||
|
"Pacific/Noumea": "NC",
|
||||||
|
"Pacific/Pago_Pago": "AS",
|
||||||
|
"Pacific/Palau": "PW",
|
||||||
|
"Pacific/Pitcairn": "PN",
|
||||||
|
"Pacific/Pohnpei": "FM",
|
||||||
|
"Pacific/Port_Moresby": "PG",
|
||||||
|
"Pacific/Rarotonga": "CK",
|
||||||
|
"Pacific/Saipan": "MP",
|
||||||
|
"Pacific/Tahiti": "PF",
|
||||||
|
"Pacific/Tarawa": "KI",
|
||||||
|
"Pacific/Tongatapu": "TO",
|
||||||
|
"Pacific/Wake": "UM",
|
||||||
|
"Pacific/Wallis": "WF",
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -43,6 +44,7 @@ type UpdateProfileInput struct {
|
|||||||
Name *string
|
Name *string
|
||||||
AvatarURL *string
|
AvatarURL *string
|
||||||
Handle *string
|
Handle *string
|
||||||
|
Bio *string
|
||||||
Country *string
|
Country *string
|
||||||
Sampradaya *string
|
Sampradaya *string
|
||||||
Gender *string
|
Gender *string
|
||||||
@@ -53,6 +55,10 @@ type UpdateProfileInput struct {
|
|||||||
PeakActiveTime *string
|
PeakActiveTime *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxBioRunes caps the profile bio. Counted in runes so the limit means the
|
||||||
|
// same thing in every script. Keep in sync with the editor's counter.
|
||||||
|
const MaxBioRunes = 200
|
||||||
|
|
||||||
// validGenders is the accepted set; "" clears the field.
|
// validGenders is the accepted set; "" clears the field.
|
||||||
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
|
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
|
||||||
|
|
||||||
@@ -87,6 +93,17 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdatePro
|
|||||||
}
|
}
|
||||||
p.Handle = &clean
|
p.Handle = &clean
|
||||||
}
|
}
|
||||||
|
if in.Bio != nil {
|
||||||
|
// "" clears. Counted in runes, not bytes, so a devotee writing in
|
||||||
|
// Devanāgarī gets the same 200 characters as a Latin-script writer
|
||||||
|
// (len() would cut them off after ~66). Collapse the CRLF that
|
||||||
|
// Windows browsers submit so the count matches what the editor shows.
|
||||||
|
b := strings.TrimSpace(strings.ReplaceAll(*in.Bio, "\r\n", "\n"))
|
||||||
|
if utf8.RuneCountInString(b) > MaxBioRunes {
|
||||||
|
return nil, fmt.Errorf("bio too long (max %d characters)", MaxBioRunes)
|
||||||
|
}
|
||||||
|
p.Bio = &b
|
||||||
|
}
|
||||||
if in.Country != nil {
|
if in.Country != nil {
|
||||||
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
|
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
|
||||||
// frontend dropdown only ever sends valid codes).
|
// frontend dropdown only ever sends valid codes).
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package service — VaniService powers "Vāṇī of the Day": one piece of content
|
||||||
|
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
|
||||||
|
// each day. The pick is chosen once per UTC day and reused for everyone; the
|
||||||
|
// picker avoids anything served in the last 90 days.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const vaniNoRepeatDays = 90
|
||||||
|
|
||||||
|
type VaniService struct {
|
||||||
|
repo *repository.VaniRepo
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
|
||||||
|
return &VaniService{repo: repo, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||||
|
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||||
|
// content to pick.
|
||||||
|
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if existing != nil {
|
||||||
|
return &existing.Payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the 90-day exclusion set, bucketed by kind.
|
||||||
|
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exclude := map[string][]string{}
|
||||||
|
for _, rid := range recent {
|
||||||
|
if kind, hex, ok := splitRef(rid); ok {
|
||||||
|
exclude[kind] = append(exclude[kind], hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := s.choose(ctx, exclude, day)
|
||||||
|
if pick == nil {
|
||||||
|
// Everything in some kind exhausted the 90-day window? Relax it.
|
||||||
|
pick = s.choose(ctx, map[string][]string{}, day)
|
||||||
|
}
|
||||||
|
if pick == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Insert(ctx, pick); err != nil {
|
||||||
|
s.logger.Warn("vani: insert failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
|
||||||
|
// consistently for everyone.
|
||||||
|
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
|
||||||
|
return &stored.Payload, nil
|
||||||
|
}
|
||||||
|
return &pick.Payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// choose tries the three content kinds in random order and returns the first
|
||||||
|
// successful sample, so each day's pick varies across content types.
|
||||||
|
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
||||||
|
kinds := []string{"verse", "prose", "transcript"}
|
||||||
|
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
||||||
|
|
||||||
|
for _, kind := range kinds {
|
||||||
|
switch kind {
|
||||||
|
case "verse":
|
||||||
|
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
|
||||||
|
return versePick(v, day)
|
||||||
|
}
|
||||||
|
case "prose":
|
||||||
|
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
|
||||||
|
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
||||||
|
}
|
||||||
|
case "transcript":
|
||||||
|
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
|
||||||
|
return transcriptPick(t, day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payload builders ---
|
||||||
|
|
||||||
|
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
|
||||||
|
title := v.Reference
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
|
||||||
|
}
|
||||||
|
return &model.VaniPick{
|
||||||
|
Day: day,
|
||||||
|
Kind: "verse",
|
||||||
|
RefID: "verse:" + v.ID.Hex(),
|
||||||
|
Payload: model.VaniPayload{
|
||||||
|
Kind: "verse",
|
||||||
|
Label: "Verse",
|
||||||
|
Title: title,
|
||||||
|
Subtitle: excerpt(v.Translation, 140),
|
||||||
|
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
|
||||||
|
BookTitle: v.BookTitle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prosePick(p *repository.ProseChapterDoc, bookTitle string, day time.Time) *model.VaniPick {
|
||||||
|
first := ""
|
||||||
|
if len(p.Paragraphs) > 0 {
|
||||||
|
first = p.Paragraphs[0]
|
||||||
|
}
|
||||||
|
return &model.VaniPick{
|
||||||
|
Day: day,
|
||||||
|
Kind: "prose",
|
||||||
|
RefID: "prose:" + p.ID.Hex(),
|
||||||
|
Payload: model.VaniPayload{
|
||||||
|
Kind: "prose",
|
||||||
|
Label: "Chapter",
|
||||||
|
Title: p.Title,
|
||||||
|
Subtitle: excerpt(first, 140),
|
||||||
|
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
|
||||||
|
BookTitle: bookTitle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
|
||||||
|
label := "Lecture"
|
||||||
|
if t.Type == "conversation" {
|
||||||
|
label = "Conversation"
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if t.Date != nil {
|
||||||
|
date = *t.Date
|
||||||
|
}
|
||||||
|
return &model.VaniPick{
|
||||||
|
Day: day,
|
||||||
|
Kind: "transcript",
|
||||||
|
RefID: "transcript:" + t.ID.Hex(),
|
||||||
|
Payload: model.VaniPayload{
|
||||||
|
Kind: "transcript",
|
||||||
|
Label: label,
|
||||||
|
Title: t.Name,
|
||||||
|
Href: "/transcriptions/" + t.Slug,
|
||||||
|
Date: date,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
|
||||||
|
|
||||||
|
func verseURL(book string, division *string, chapter, verse int) string {
|
||||||
|
switch book {
|
||||||
|
case "bg":
|
||||||
|
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
|
||||||
|
case "sb":
|
||||||
|
if division != nil {
|
||||||
|
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
|
||||||
|
case "cc":
|
||||||
|
if division != nil {
|
||||||
|
return fmt.Sprintf("/books/cc/%s/%d/%d", *division, chapter, verse)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
|
||||||
|
case "sb1962":
|
||||||
|
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
|
||||||
|
case "iso":
|
||||||
|
return fmt.Sprintf("/books/iso/%d", chapter)
|
||||||
|
case "noi":
|
||||||
|
return fmt.Sprintf("/books/noi/%d", chapter)
|
||||||
|
case "bs":
|
||||||
|
return fmt.Sprintf("/books/bs/5/%d", verse)
|
||||||
|
case "mm":
|
||||||
|
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
|
||||||
|
case "nbs":
|
||||||
|
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func proseURL(book string, volume int, docType, slug string) string {
|
||||||
|
switch {
|
||||||
|
case book == "kb":
|
||||||
|
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
||||||
|
case docType == "chapter-summary":
|
||||||
|
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
|
||||||
|
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/books/%s/%s", book, slug)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("/books/%s/%s", book, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func splitRef(ref string) (kind, hex string, ok bool) {
|
||||||
|
if i := strings.IndexByte(ref, ':'); i > 0 {
|
||||||
|
return ref[:i], ref[i+1:], true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
var wsRe = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
|
||||||
|
// ellipsis — a clean one-line teaser for the CTA.
|
||||||
|
func excerpt(html string, n int) string {
|
||||||
|
s := htmlTagRe.ReplaceAllString(html, "")
|
||||||
|
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:n])) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE post_reports DROP COLUMN IF EXISTS resolution_note;
|
||||||
|
ALTER TABLE comment_reports DROP COLUMN IF EXISTS resolution_note;
|
||||||
|
ALTER TABLE user_reports DROP COLUMN IF EXISTS resolution_note;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- A resolution note captured when an admin resolves a report (shown in the
|
||||||
|
-- admin reports table). Additive nullable column on each report table.
|
||||||
|
ALTER TABLE post_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT;
|
||||||
|
ALTER TABLE comment_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT;
|
||||||
|
ALTER TABLE user_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_limit_overrides_nonneg;
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS bookmark_limit,
|
||||||
|
DROP COLUMN IF EXISTS public_collection_limit,
|
||||||
|
DROP COLUMN IF EXISTS private_collection_limit;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Per-user save-limit overrides, set by admins from the panel. NULL means
|
||||||
|
-- "use the plan default" (free: 1 bookmark, 5 public + 5 private collections;
|
||||||
|
-- premium: 5 bookmarks, unlimited collections). A non-NULL value caps that
|
||||||
|
-- resource for this specific user, regardless of plan.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS bookmark_limit INT,
|
||||||
|
ADD COLUMN IF NOT EXISTS public_collection_limit INT,
|
||||||
|
ADD COLUMN IF NOT EXISTS private_collection_limit INT;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_limit_overrides_nonneg CHECK (
|
||||||
|
(bookmark_limit IS NULL OR bookmark_limit >= 0) AND
|
||||||
|
(public_collection_limit IS NULL OR public_collection_limit >= 0) AND
|
||||||
|
(private_collection_limit IS NULL OR private_collection_limit >= 0)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS audio_progress;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Per-user audio playback resume points.
|
||||||
|
--
|
||||||
|
-- One row per (user, audio file). The floating audio player writes the
|
||||||
|
-- last-known position for a track so it can resume there next time the
|
||||||
|
-- same file is played, on any device the user is signed in on.
|
||||||
|
--
|
||||||
|
-- audio_key is the track URL with its query string / hash stripped
|
||||||
|
-- (audio URLs may be signed or cache-busted, so the path is the stable
|
||||||
|
-- identity). The schema is owned by the frontend; the backend only
|
||||||
|
-- stores and round-trips it. position_seconds/duration_seconds mirror
|
||||||
|
-- the HTMLAudioElement's currentTime/duration (floats).
|
||||||
|
--
|
||||||
|
-- updated_at is supplied by the client, not defaulted to now(), so the
|
||||||
|
-- upsert can resolve multi-tab / multi-device conflicts with a
|
||||||
|
-- newest-wins rule (see AudioProgressRepo.Upsert) and so anonymous
|
||||||
|
-- IndexedDB entries keep their original timestamp when synced on login.
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_progress (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
audio_key TEXT NOT NULL,
|
||||||
|
position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds DOUBLE PRECISION,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, audio_key),
|
||||||
|
CHECK (position_seconds >= 0)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS vani_of_the_day;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- "Vāṇī of the Day" — one randomly-chosen piece of content (a prose chapter,
|
||||||
|
-- a verse, or a transcript/lecture) surfaced on the home page each day.
|
||||||
|
--
|
||||||
|
-- One row per calendar day (UTC). The pick is chosen once — the first request
|
||||||
|
-- of the day writes it and everyone sees the same item that day. `ref_id`
|
||||||
|
-- (`<kind>:<mongo _id hex>`) identifies the underlying content so the picker
|
||||||
|
-- can avoid repeating anything served in the last 90 days. `payload` is the
|
||||||
|
-- prebuilt display blob (title/subtitle/href/…) so the CTA renders without
|
||||||
|
-- re-reading Mongo.
|
||||||
|
CREATE TABLE IF NOT EXISTS vani_of_the_day (
|
||||||
|
day DATE PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
ref_id TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Serves the "not in the last 90 days" lookup (scan recent rows by ref_id).
|
||||||
|
CREATE INDEX IF NOT EXISTS vani_of_the_day_ref_day_idx
|
||||||
|
ON vani_of_the_day (ref_id, day DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS users_disciple_id_key;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS disciple_id;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Direct-disciple linkage. Śrīla Prabhupāda's own initiated disciples have
|
||||||
|
-- started joining the platform; an admin links their account to the matching
|
||||||
|
-- row in the Mongo `disciples` collection so the site can mark them
|
||||||
|
-- authentically (a badge on their profile, a highlight in /disciples).
|
||||||
|
--
|
||||||
|
-- disciple_id holds the Mongo ObjectID hex (24 chars). It's a cross-store
|
||||||
|
-- reference, so no foreign key is possible — the admin endpoint validates the
|
||||||
|
-- id exists in Mongo before writing, and a dangling id degrades gracefully
|
||||||
|
-- (the name simply doesn't hydrate).
|
||||||
|
--
|
||||||
|
-- The partial UNIQUE index enforces the one-to-one rule: a given disciple can
|
||||||
|
-- be claimed by at most one account, so two users can't both present as the
|
||||||
|
-- same disciple. NULLs are unconstrained, which leaves the overwhelming
|
||||||
|
-- majority of users unaffected and keeps the index tiny.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS disciple_id TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_disciple_id_key
|
||||||
|
ON users (disciple_id) WHERE disciple_id IS NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS disciple_announcements;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- One row per account whose arrival as a direct disciple of Śrīla Prabhupāda
|
||||||
|
-- has been announced to the community.
|
||||||
|
--
|
||||||
|
-- This table exists purely for idempotency. Announcing writes a notification
|
||||||
|
-- to *every* user, so it must happen at most once per devotee — not again when
|
||||||
|
-- an admin corrects which disciple record they're linked to, not again on a
|
||||||
|
-- double-clicked button, and not again if the process restarts mid-fan-out.
|
||||||
|
-- The insert is `ON CONFLICT DO NOTHING` and only a caller that actually
|
||||||
|
-- inserted the row goes on to broadcast, which makes the guard atomic.
|
||||||
|
--
|
||||||
|
-- Keyed on user_id (not disciple_id) deliberately: we are announcing that a
|
||||||
|
-- *person* has joined. Re-pointing their link at a different disciple record
|
||||||
|
-- corrects a mistake; it isn't a second arrival.
|
||||||
|
CREATE TABLE IF NOT EXISTS disciple_announcements (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
-- The Mongo disciple ObjectID they were linked to at announcement time,
|
||||||
|
-- kept for auditing (which record prompted the announcement).
|
||||||
|
disciple_id TEXT NOT NULL,
|
||||||
|
announced_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS bio;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Profile bio — a short self-introduction shown on the profile header.
|
||||||
|
--
|
||||||
|
-- Plain text (never HTML): `@handle` mentions are stored verbatim and linkified
|
||||||
|
-- at render time by the frontend, so nothing user-supplied is ever trusted as
|
||||||
|
-- markup. NULL means "no bio set", which is distinct from an empty string —
|
||||||
|
-- the update path clears to NULL rather than storing ''.
|
||||||
|
--
|
||||||
|
-- The 200-character cap is enforced in the service layer (by rune count, so a
|
||||||
|
-- devotee writing in Devanāgarī gets the same 200 characters a Latin-script
|
||||||
|
-- writer does). No CHECK constraint here: the column stays permissive so a
|
||||||
|
-- future cap change doesn't need a migration on a large table.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS bio TEXT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP TABLE IF EXISTS message_reports;
|
||||||
|
DROP TABLE IF EXISTS message_stars;
|
||||||
|
DROP TABLE IF EXISTS message_attachments;
|
||||||
|
DROP TABLE IF EXISTS message_hides;
|
||||||
|
DROP TABLE IF EXISTS messages;
|
||||||
|
DROP TABLE IF EXISTS conversation_participants;
|
||||||
|
DROP TABLE IF EXISTS conversations;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
-- Chat — DMs, group threads, and Kautilya (AI) threads.
|
||||||
|
--
|
||||||
|
-- One pipeline serves all three. `conversations.kind` is the only thing that
|
||||||
|
-- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a
|
||||||
|
-- second implementation, and the message list, composer, pagination and
|
||||||
|
-- context menu are written once.
|
||||||
|
--
|
||||||
|
-- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and
|
||||||
|
-- timestamps collide; a gapless integer gives stable ordering, cheap unread
|
||||||
|
-- maths (last_seq - last_read_seq), and a sync cursor the client can resume
|
||||||
|
-- from after a reconnect (?after_seq=N).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- dm → exactly two participants
|
||||||
|
-- group → many participants, has a title/avatar
|
||||||
|
-- ai → one participant; the other side is Kautilya (no user row)
|
||||||
|
kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')),
|
||||||
|
title VARCHAR(120), -- groups (and AI thread titles)
|
||||||
|
avatar_key TEXT, -- R2 key, groups only
|
||||||
|
-- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes
|
||||||
|
-- "one thread per pair" a database guarantee instead of a service-layer
|
||||||
|
-- race between two people opening the chat simultaneously.
|
||||||
|
dm_key TEXT,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
-- Monotonic message counter. Bumped inside the send transaction, which
|
||||||
|
-- serialises assignment per conversation and leaves no gaps.
|
||||||
|
last_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
-- Denormalized purely for inbox ordering; the preview text and its sender
|
||||||
|
-- are read with a LATERAL join on (conversation_id, seq DESC) so there is
|
||||||
|
-- one source of truth for message content even after an edit or delete.
|
||||||
|
last_message_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (kind <> 'dm' OR dm_key IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq
|
||||||
|
ON conversations (dm_key) WHERE kind = 'dm';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(8) NOT NULL DEFAULT 'member'
|
||||||
|
CHECK (role IN ('owner', 'admin', 'member')),
|
||||||
|
-- Delivery/read state lives here, per participant — NOT per message. A
|
||||||
|
-- receipt row per (message × participant) would grow with the product of
|
||||||
|
-- both; two cursors give the same three tick states at constant size:
|
||||||
|
-- sent → the row exists
|
||||||
|
-- delivered → last_delivered_seq >= message.seq
|
||||||
|
-- read → last_read_seq >= message.seq
|
||||||
|
last_read_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_delivered_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
muted_until TIMESTAMPTZ,
|
||||||
|
-- "Friends since" on the chat profile reads joined_at.
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
-- Non-null = left the group. Kept (rather than deleted) so their past
|
||||||
|
-- messages still resolve to a member row when rendering history.
|
||||||
|
left_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The inbox query: this user's threads, most recent first. Partial on left_at
|
||||||
|
-- so departed groups drop out without a filter on every read.
|
||||||
|
CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx
|
||||||
|
ON conversation_participants (user_id) WHERE left_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
-- NULL for messages no user wrote: 'system' (membership changes) and
|
||||||
|
-- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread
|
||||||
|
-- readable for everyone else.
|
||||||
|
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
kind VARCHAR(12) NOT NULL DEFAULT 'text'
|
||||||
|
CHECK (kind IN ('text', 'system', 'assistant')),
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Inline formatting and detected links as offset/length spans
|
||||||
|
-- ([{type,offset,length}]), never HTML. Storing spans instead of markup
|
||||||
|
-- keeps rendering injection-safe and lets each client render natively.
|
||||||
|
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
-- Client-generated id, so a retried send is deduped rather than doubled.
|
||||||
|
client_msg_id UUID,
|
||||||
|
reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
-- Set when this message was forwarded from another. SET NULL so deleting
|
||||||
|
-- the original leaves the forward intact.
|
||||||
|
forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
edited_at TIMESTAMPTZ,
|
||||||
|
-- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and
|
||||||
|
-- other participants' clients can replace the bubble in place.
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (conversation_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- History paging, newest first, and the LATERAL that fetches each inbox row's
|
||||||
|
-- last message.
|
||||||
|
CREATE INDEX IF NOT EXISTS messages_conversation_idx
|
||||||
|
ON messages (conversation_id, seq DESC);
|
||||||
|
|
||||||
|
-- Idempotent send. Partial because client_msg_id is NULL for system and
|
||||||
|
-- assistant messages, which no client retries.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup
|
||||||
|
ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone):
|
||||||
|
-- this hides a message from one person's view while others keep seeing it.
|
||||||
|
CREATE TABLE IF NOT EXISTS message_hides (
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
-- Denormalized from the parent message so the chat profile's "shared
|
||||||
|
-- media" and "shared links" tabs are one index range scan, with no join
|
||||||
|
-- back through messages.
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
ord SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
kind VARCHAR(8) NOT NULL
|
||||||
|
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')),
|
||||||
|
|
||||||
|
-- Media (kind in image/video/audio/file). r2_key points at the PRIVATE
|
||||||
|
-- bucket: chat media is never public-by-URL, it is read through a
|
||||||
|
-- participant-checked presigned GET.
|
||||||
|
r2_key TEXT,
|
||||||
|
mime VARCHAR(128),
|
||||||
|
size_bytes BIGINT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
blurhash VARCHAR(64), -- instant placeholder while loading
|
||||||
|
file_name TEXT,
|
||||||
|
|
||||||
|
-- Link preview (kind = 'link'). Unfurled server-side AFTER the message is
|
||||||
|
-- persisted, so a slow remote page never delays the send. Absent when the
|
||||||
|
-- sender dismissed the preview in the composer.
|
||||||
|
url TEXT,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
site_name VARCHAR(120),
|
||||||
|
image_url TEXT,
|
||||||
|
|
||||||
|
-- Shared post / comment / profile / collection (kind = 'entity').
|
||||||
|
-- entity_id is TEXT because these ids are not all UUIDs, and there is
|
||||||
|
-- deliberately no FK: the entity may be deleted or turn private later.
|
||||||
|
-- `snapshot` is a fallback for rendering, but permission is re-checked
|
||||||
|
-- per viewer at read time — a collection made private must stop resolving
|
||||||
|
-- for the recipient, so the snapshot alone must never be trusted.
|
||||||
|
entity_type VARCHAR(16) CHECK (entity_type IS NULL OR
|
||||||
|
entity_type IN ('post', 'comment', 'profile', 'collection')),
|
||||||
|
entity_id TEXT,
|
||||||
|
snapshot JSONB,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS message_attachments_message_idx
|
||||||
|
ON message_attachments (message_id, ord);
|
||||||
|
|
||||||
|
-- The chat profile's media / links tabs.
|
||||||
|
CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx
|
||||||
|
ON message_attachments (conversation_id, kind, created_at DESC);
|
||||||
|
|
||||||
|
-- Starred messages are per-user, not per-conversation: two people in the same
|
||||||
|
-- thread star different things.
|
||||||
|
CREATE TABLE IF NOT EXISTS message_stars (
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
-- Denormalized so "starred in this chat" is a single index range scan.
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx
|
||||||
|
ON message_stars (user_id, conversation_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Reported messages. `snapshot` is the whole point: it copies the reported
|
||||||
|
-- message (and limited surrounding context) at report time so moderators read
|
||||||
|
-- a report record and never need read access to live threads. message_id is
|
||||||
|
-- SET NULL on delete so a report survives the sender deleting the evidence.
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||||
|
reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
category VARCHAR(32) NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution_note TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx
|
||||||
|
ON message_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS conversation_participants_role_idx;
|
||||||
|
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_send_policy_check;
|
||||||
|
ALTER TABLE conversations DROP COLUMN IF EXISTS send_policy;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Who may post in a group.
|
||||||
|
--
|
||||||
|
-- 'everyone' keeps existing groups behaving exactly as they do today, which is
|
||||||
|
-- why it is the default: this migration must not silently mute anyone.
|
||||||
|
-- 'admins' makes the thread an announcement channel — owners and admins post,
|
||||||
|
-- everyone else reads.
|
||||||
|
--
|
||||||
|
-- DMs and Kautilya threads ignore this: a two-party conversation has no
|
||||||
|
-- administrators, and the AI thread has one participant.
|
||||||
|
ALTER TABLE conversations
|
||||||
|
ADD COLUMN IF NOT EXISTS send_policy VARCHAR(8) NOT NULL DEFAULT 'everyone';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'conversations_send_policy_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE conversations
|
||||||
|
ADD CONSTRAINT conversations_send_policy_check
|
||||||
|
CHECK (send_policy IN ('everyone', 'admins'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- "Who are this group's admins?" runs on every post in an admins-only thread.
|
||||||
|
CREATE INDEX IF NOT EXISTS conversation_participants_role_idx
|
||||||
|
ON conversation_participants (conversation_id, role) WHERE left_at IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS chat_settings;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Per-user chat preferences.
|
||||||
|
--
|
||||||
|
-- A row is created on first change, so absence means "all defaults". That is
|
||||||
|
-- why every column carries the product default rather than something neutral:
|
||||||
|
-- reading a missing row and reading an untouched row must give the same answer.
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_settings (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Read receipts are reciprocal: turning them off also stops you seeing
|
||||||
|
-- other people's. Enforced in the service, not here.
|
||||||
|
read_receipts BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Who may START a conversation with this user:
|
||||||
|
-- anyone — no restriction (default)
|
||||||
|
-- friends — people they follow, or who follow them, accepted
|
||||||
|
-- account_age — accounts at least min_account_age_months old
|
||||||
|
-- same_gender — accounts whose gender matches theirs; requires the user
|
||||||
|
-- to have set their own gender, since there is nothing to
|
||||||
|
-- match against otherwise
|
||||||
|
who_can_message VARCHAR(16) NOT NULL DEFAULT 'anyone',
|
||||||
|
|
||||||
|
-- Only meaningful when who_can_message = 'account_age'.
|
||||||
|
min_account_age_months INTEGER NOT NULL DEFAULT 3,
|
||||||
|
|
||||||
|
-- Consent for using this user's chats as training data for the Kautilya
|
||||||
|
-- assistant. Recorded now so the choice exists before the feature does —
|
||||||
|
-- collecting consent retroactively is worse than collecting it early.
|
||||||
|
train_kautilya BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT chat_settings_who_can_message_check
|
||||||
|
CHECK (who_can_message IN ('anyone', 'friends', 'account_age', 'same_gender')),
|
||||||
|
CONSTRAINT chat_settings_account_age_check
|
||||||
|
CHECK (min_account_age_months BETWEEN 1 AND 120)
|
||||||
|
);
|
||||||
+3
-3
@@ -32,9 +32,9 @@ func CountryFromCloudflare(r *http.Request) string {
|
|||||||
return cc
|
return cc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientIP returns the best guess at the caller's IP. The chi RealIP
|
// ClientIP returns the best guess at the caller's IP. The middleware.RealIP
|
||||||
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
|
// middleware already rewrites r.RemoteAddr to the real client (right-most
|
||||||
// X-Real-IP, so we just strip the port.
|
// X-Forwarded-For entry from the trusted edge proxy), so we just strip any port.
|
||||||
func ClientIP(r *http.Request) string {
|
func ClientIP(r *http.Request) string {
|
||||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotConfigured is returned by the authorize helpers when realtime is
|
||||||
|
// disabled. Callers should surface it as a 503: without Soketi there is no
|
||||||
|
// subscription to authorize, and returning a bogus signature would just fail
|
||||||
|
// opaquely at the client.
|
||||||
|
var ErrNotConfigured = errors.New("realtime is not configured")
|
||||||
|
|
||||||
|
// Channel-name prefixes, exported so consumers that parse channel names
|
||||||
|
// (the Soketi webhook) stay in step with the ones that build them.
|
||||||
|
const (
|
||||||
|
UserChannelPrefix = "private-notifications."
|
||||||
|
LegacyUserChannelPrefix = "notifications."
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserChannel is the per-user private channel carrying notification nudges.
|
||||||
|
// Private (not public) so a subscription requires a signature the BFF only
|
||||||
|
// issues to that user — without it, anyone who learns a user's UUID can read
|
||||||
|
// their notification stream.
|
||||||
|
func UserChannel(userID string) string { return UserChannelPrefix + userID }
|
||||||
|
|
||||||
|
// LegacyUserChannel is the public channel the frontend used before private
|
||||||
|
// channels existed. Still published to during the migration window so
|
||||||
|
// service-worker-cached bundles keep working; delete once clients have
|
||||||
|
// rolled over. See TriggerUser.
|
||||||
|
func LegacyUserChannel(userID string) string { return LegacyUserChannelPrefix + userID }
|
||||||
|
|
||||||
|
// AuthorizeChannel signs a private-channel subscription.
|
||||||
|
//
|
||||||
|
// Pusher's scheme: the client sends its socket_id and the channel it wants;
|
||||||
|
// the app server — the only party holding the app secret — returns
|
||||||
|
// "<key>:<hmac_sha256(socket_id:channel_name, secret)>". Soketi verifies with
|
||||||
|
// its own copy of the secret. The signature is the whole authorization, so
|
||||||
|
// callers MUST check the caller may access the channel before calling this.
|
||||||
|
func (c *SoketiClient) AuthorizeChannel(socketID, channel string) (string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return "", ErrNotConfigured
|
||||||
|
}
|
||||||
|
if socketID == "" || channel == "" {
|
||||||
|
return "", errors.New("socket_id and channel_name are required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(channel, "presence-") {
|
||||||
|
return "", errors.New("presence channels must use AuthorizePresence")
|
||||||
|
}
|
||||||
|
return c.sign(socketID + ":" + channel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresenceData is the member identity Soketi publishes to everyone else on a
|
||||||
|
// presence channel. It is echoed to other subscribers verbatim, so UserInfo
|
||||||
|
// must carry only what those members may already see — never an email.
|
||||||
|
type PresenceData struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserInfo map[string]any `json:"user_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizePresence signs a presence-channel subscription, returning both the
|
||||||
|
// auth signature and the channel_data the client must echo back. Presence
|
||||||
|
// folds channel_data into the signed string, so the member identity can't be
|
||||||
|
// tampered with client-side.
|
||||||
|
func (c *SoketiClient) AuthorizePresence(socketID, channel string, data PresenceData) (auth, channelData string, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return "", "", ErrNotConfigured
|
||||||
|
}
|
||||||
|
if socketID == "" || channel == "" {
|
||||||
|
return "", "", errors.New("socket_id and channel_name are required")
|
||||||
|
}
|
||||||
|
if data.UserID == "" {
|
||||||
|
return "", "", errors.New("presence data requires a user_id")
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("marshal channel_data: %w", err)
|
||||||
|
}
|
||||||
|
channelData = string(encoded)
|
||||||
|
return c.sign(socketID + ":" + channel + ":" + channelData), channelData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign returns the "<key>:<signature>" auth string for an already-built
|
||||||
|
// string-to-sign.
|
||||||
|
func (c *SoketiClient) sign(stringToSign string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(c.secret))
|
||||||
|
mac.Write([]byte(stringToSign))
|
||||||
|
return c.key + ":" + hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerUser publishes a per-user event to both the private channel and the
|
||||||
|
// legacy public one.
|
||||||
|
//
|
||||||
|
// Dual-publish exists only for the migration: the frontend is a PWA, so a
|
||||||
|
// service-worker-cached bundle can keep subscribing to the public channel for
|
||||||
|
// a while after deploy, and a straight rename would silently stop its
|
||||||
|
// notifications. Once clients have rolled over, drop the legacy Trigger and
|
||||||
|
// this becomes a single publish.
|
||||||
|
//
|
||||||
|
// The private publish decides the error result; a legacy failure is not worth
|
||||||
|
// failing the caller over.
|
||||||
|
func (c *SoketiClient) TriggerUser(ctx context.Context, userID, event string, data any) error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.Trigger(ctx, UserChannel(userID), event, data)
|
||||||
|
if legacyErr := c.Trigger(ctx, LegacyUserChannel(userID), event, data); legacyErr != nil {
|
||||||
|
c.logger.Debug("realtime: legacy channel publish failed", zap.Error(legacyErr))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testClient builds a client with fixed credentials so signatures are
|
||||||
|
// reproducible.
|
||||||
|
func testClient(t *testing.T) *SoketiClient {
|
||||||
|
t.Helper()
|
||||||
|
c := NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop())
|
||||||
|
if c == nil {
|
||||||
|
t.Fatal("NewSoketiClient returned nil for a fully configured client")
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expected signatures are HMAC-SHA256 vectors computed independently of
|
||||||
|
// this package. Recomputing them with the same code under test would only
|
||||||
|
// prove the code equals itself; a fixed vector catches a change to the
|
||||||
|
// string-to-sign format, which is what Soketi actually verifies against.
|
||||||
|
func TestAuthorizeChannel_MatchesKnownVector(t *testing.T) {
|
||||||
|
auth, err := testClient(t).AuthorizeChannel("1234.5678", "private-notifications.user-a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthorizeChannel: %v", err)
|
||||||
|
}
|
||||||
|
const want = "test-key:9c436d58a9d030f68b35d636bb3e930c471d5c11edec6745c969e4eee1c3e3df"
|
||||||
|
if auth != want {
|
||||||
|
t.Errorf("auth = %q, want %q", auth, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizePresence_MatchesKnownVector(t *testing.T) {
|
||||||
|
auth, channelData, err := testClient(t).AuthorizePresence(
|
||||||
|
"1234.5678", "presence-conversation.c1", PresenceData{UserID: "user-a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthorizePresence: %v", err)
|
||||||
|
}
|
||||||
|
if channelData != `{"user_id":"user-a"}` {
|
||||||
|
t.Errorf("channel_data = %q", channelData)
|
||||||
|
}
|
||||||
|
const want = "test-key:cf5a222b506d46383cb4edac7707ba620e4ba2bbb83f64d1cf6463b4241efd24"
|
||||||
|
if auth != want {
|
||||||
|
t.Errorf("auth = %q, want %q", auth, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence signing folds channel_data into the signed string; routing a
|
||||||
|
// presence channel through AuthorizeChannel would produce a signature Soketi
|
||||||
|
// rejects, so it's refused outright rather than silently mis-signed.
|
||||||
|
func TestAuthorizeChannel_RejectsPresenceChannel(t *testing.T) {
|
||||||
|
if _, err := testClient(t).AuthorizeChannel("1234.5678", "presence-conversation.c1"); err == nil {
|
||||||
|
t.Fatal("expected an error for a presence channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorize_NilClientReportsNotConfigured(t *testing.T) {
|
||||||
|
var c *SoketiClient
|
||||||
|
if _, err := c.AuthorizeChannel("1234.5678", "private-x"); !errors.Is(err, ErrNotConfigured) {
|
||||||
|
t.Errorf("AuthorizeChannel err = %v, want ErrNotConfigured", err)
|
||||||
|
}
|
||||||
|
if _, _, err := c.AuthorizePresence("1234.5678", "presence-x", PresenceData{UserID: "u"}); !errors.Is(err, ErrNotConfigured) {
|
||||||
|
t.Errorf("AuthorizePresence err = %v, want ErrNotConfigured", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorize_RequiresArguments(t *testing.T) {
|
||||||
|
c := testClient(t)
|
||||||
|
if _, err := c.AuthorizeChannel("", "private-x"); err == nil {
|
||||||
|
t.Error("expected an error for an empty socket_id")
|
||||||
|
}
|
||||||
|
if _, err := c.AuthorizeChannel("1234.5678", ""); err == nil {
|
||||||
|
t.Error("expected an error for an empty channel")
|
||||||
|
}
|
||||||
|
if _, _, err := c.AuthorizePresence("1234.5678", "presence-x", PresenceData{}); err == nil {
|
||||||
|
t.Error("expected an error for presence data with no user_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserChannels(t *testing.T) {
|
||||||
|
if got := UserChannel("u1"); got != "private-notifications.u1" {
|
||||||
|
t.Errorf("UserChannel = %q", got)
|
||||||
|
}
|
||||||
|
// The legacy name must stay public-prefixed: it exists so already-cached
|
||||||
|
// clients keep receiving events during the migration.
|
||||||
|
if got := LegacyUserChannel("u1"); got != "notifications.u1" || strings.HasPrefix(got, "private-") {
|
||||||
|
t.Errorf("LegacyUserChannel = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Package unfurl fetches link preview metadata for URLs users paste into chat.
|
||||||
|
//
|
||||||
|
// Fetching a user-supplied URL from the server is a server-side request forgery
|
||||||
|
// primitive: without care, "https://x" can be pointed at 169.254.169.254 (cloud
|
||||||
|
// metadata), at a database on 10.x, or at localhost. Everything below exists to
|
||||||
|
// make that impossible:
|
||||||
|
//
|
||||||
|
// - only http/https,
|
||||||
|
// - every connection is vetted at the RESOLVED IP, so a hostname that
|
||||||
|
// resolves to a private address is refused (and re-checked on each redirect
|
||||||
|
// hop, which is what defeats DNS rebinding),
|
||||||
|
// - redirects capped, response size capped, timeouts on everything,
|
||||||
|
// - only text/html is parsed.
|
||||||
|
//
|
||||||
|
// The metadata is deliberately fetched by the server rather than accepted from
|
||||||
|
// the client. A client that could supply the title and image could make any
|
||||||
|
// link claim to be anything.
|
||||||
|
package unfurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBlocked is returned for a URL that resolves somewhere we refuse to fetch.
|
||||||
|
var ErrBlocked = errors.New("refusing to fetch that address")
|
||||||
|
|
||||||
|
// ErrNotHTML is returned when the response isn't a page we can read metadata
|
||||||
|
// from — a PDF or an image has nothing to unfurl.
|
||||||
|
var ErrNotHTML = errors.New("not an HTML page")
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRedirects = 3
|
||||||
|
maxBodyBytes = 512 << 10 // 512 KiB is far more than any <head>
|
||||||
|
fetchTimeout = 6 * time.Second
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
maxFieldRunes = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preview is the metadata rendered as a link card.
|
||||||
|
type Preview struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SiteName string `json:"site_name,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetcher retrieves previews. Safe for concurrent use.
|
||||||
|
type Fetcher struct {
|
||||||
|
client *http.Client
|
||||||
|
// allowLoopback exists only so the package's own tests can point at an
|
||||||
|
// httptest server. It is unexported and never set outside this package.
|
||||||
|
allowLoopback bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Fetcher with every guard enabled.
|
||||||
|
func New() *Fetcher {
|
||||||
|
f := &Fetcher{}
|
||||||
|
f.client = &http.Client{
|
||||||
|
Timeout: fetchTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: dialTimeout,
|
||||||
|
// Control runs after DNS resolution with the address actually
|
||||||
|
// being dialled, so it sees through a hostname that points at
|
||||||
|
// a private range — including one that changes between the
|
||||||
|
// lookup and the connection.
|
||||||
|
Control: func(network, address string, _ syscall.RawConn) error {
|
||||||
|
return f.checkAddr(network, address)
|
||||||
|
},
|
||||||
|
}).DialContext,
|
||||||
|
TLSHandshakeTimeout: dialTimeout,
|
||||||
|
ResponseHeaderTimeout: fetchTimeout,
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
},
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= maxRedirects {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
if !allowedScheme(req.URL) {
|
||||||
|
return ErrBlocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) checkAddr(network, address string) error {
|
||||||
|
if network != "tcp4" && network != "tcp6" && network != "tcp" {
|
||||||
|
return ErrBlocked
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return ErrBlocked
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return ErrBlocked
|
||||||
|
}
|
||||||
|
if f.allowLoopback && ip.IsLoopback() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if blockedIP(ip) {
|
||||||
|
return ErrBlocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockedIP reports whether an address is one we refuse to talk to.
|
||||||
|
func blockedIP(ip net.IP) bool {
|
||||||
|
// Normalise IPv4-mapped IPv6 (::ffff:127.0.0.1) so the v4 rules apply.
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
ip = v4
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
||||||
|
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||||
|
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Ranges Go's helpers don't cover but which still reach infrastructure.
|
||||||
|
for _, cidr := range extraBlocked {
|
||||||
|
if cidr.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var extraBlocked = func() []*net.IPNet {
|
||||||
|
nets := []string{
|
||||||
|
"100.64.0.0/10", // CGNAT
|
||||||
|
"192.0.0.0/24", // IETF protocol assignments
|
||||||
|
"192.0.2.0/24", // TEST-NET-1
|
||||||
|
"198.18.0.0/15", // benchmarking
|
||||||
|
"198.51.100.0/24", // TEST-NET-2
|
||||||
|
"203.0.113.0/24", // TEST-NET-3
|
||||||
|
"240.0.0.0/4", // reserved
|
||||||
|
"::/128", // unspecified
|
||||||
|
"64:ff9b::/96", // NAT64, can map to private v4
|
||||||
|
}
|
||||||
|
out := make([]*net.IPNet, 0, len(nets))
|
||||||
|
for _, n := range nets {
|
||||||
|
if _, cidr, err := net.ParseCIDR(n); err == nil {
|
||||||
|
out = append(out, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
func allowedScheme(u *url.URL) bool {
|
||||||
|
return u.Scheme == "http" || u.Scheme == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch retrieves a preview for rawURL.
|
||||||
|
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Preview, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse url: %w", err)
|
||||||
|
}
|
||||||
|
if !allowedScheme(u) || u.Host == "" {
|
||||||
|
return nil, ErrBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
// Identify honestly; some sites serve richer metadata to a known crawler
|
||||||
|
// and it gives operators someone to contact.
|
||||||
|
req.Header.Set("User-Agent", "PrabhupadaSaysBot/1.0 (+https://prabhupadasays.org)")
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
|
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// A blocked dial surfaces wrapped in a url.Error; unwrap so callers can
|
||||||
|
// tell "refused on purpose" from "the site is down".
|
||||||
|
if errors.Is(err, ErrBlocked) {
|
||||||
|
return nil, ErrBlocked
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("unfurl status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "" &&
|
||||||
|
!strings.HasPrefix(strings.ToLower(ct), "text/html") &&
|
||||||
|
!strings.HasPrefix(strings.ToLower(ct), "application/xhtml") {
|
||||||
|
return nil, ErrNotHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resp.Request.URL is the FINAL url after redirects — relative images must
|
||||||
|
// resolve against where we actually landed.
|
||||||
|
final := resp.Request.URL
|
||||||
|
p := extract(string(body))
|
||||||
|
p.URL = final.String()
|
||||||
|
if p.ImageURL != "" {
|
||||||
|
if abs, err := final.Parse(p.ImageURL); err == nil && allowedScheme(abs) {
|
||||||
|
p.ImageURL = abs.String()
|
||||||
|
} else {
|
||||||
|
p.ImageURL = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Title == "" && p.Description == "" {
|
||||||
|
return nil, ErrNotHTML
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
metaRe = regexp.MustCompile(`(?is)<meta\s+([^>]*?)/?>`)
|
||||||
|
attrRe = regexp.MustCompile(`(?is)([a-z0-9:_.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
|
||||||
|
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||||
|
headRe = regexp.MustCompile(`(?is)</head\s*>`)
|
||||||
|
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// extract pulls Open Graph metadata out of a document's head.
|
||||||
|
//
|
||||||
|
// Written against the meta tags themselves rather than a full HTML parse: the
|
||||||
|
// fields wanted here are a handful of well-formed <meta> elements, and this
|
||||||
|
// keeps the BFF free of another dependency.
|
||||||
|
func extract(doc string) *Preview {
|
||||||
|
// Metadata lives in <head>; stopping there avoids scanning a whole page.
|
||||||
|
if loc := headRe.FindStringIndex(doc); loc != nil {
|
||||||
|
doc = doc[:loc[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Preview{}
|
||||||
|
for _, m := range metaRe.FindAllStringSubmatch(doc, -1) {
|
||||||
|
attrs := parseAttrs(m[1])
|
||||||
|
key := attrs["property"]
|
||||||
|
if key == "" {
|
||||||
|
key = attrs["name"]
|
||||||
|
}
|
||||||
|
content := attrs["content"]
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "og:title", "twitter:title":
|
||||||
|
setIfEmpty(&p.Title, content)
|
||||||
|
case "og:description", "twitter:description", "description":
|
||||||
|
setIfEmpty(&p.Description, content)
|
||||||
|
case "og:site_name":
|
||||||
|
setIfEmpty(&p.SiteName, content)
|
||||||
|
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
|
||||||
|
setIfEmpty(&p.ImageURL, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Title == "" {
|
||||||
|
if m := titleRe.FindStringSubmatch(doc); m != nil {
|
||||||
|
p.Title = clean(tagRe.ReplaceAllString(m[1], ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Title = truncate(p.Title, maxFieldRunes)
|
||||||
|
p.Description = truncate(p.Description, maxFieldRunes)
|
||||||
|
p.SiteName = truncate(p.SiteName, maxFieldRunes)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAttrs(s string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, m := range attrRe.FindAllStringSubmatch(s, -1) {
|
||||||
|
value := m[3]
|
||||||
|
if value == "" {
|
||||||
|
value = m[4]
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
value = m[5]
|
||||||
|
}
|
||||||
|
out[strings.ToLower(m[1])] = clean(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIfEmpty(dst *string, v string) {
|
||||||
|
if *dst == "" {
|
||||||
|
*dst = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clean(s string) string {
|
||||||
|
return strings.TrimSpace(html.UnescapeString(strings.Join(strings.Fields(s), " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:n])) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package unfurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The blocklist is the security boundary of this package: everything else is
|
||||||
|
// plumbing around it. A URL that resolves into any of these ranges must never
|
||||||
|
// be fetched, or the BFF becomes a proxy into the private network.
|
||||||
|
func TestBlockedIP(t *testing.T) {
|
||||||
|
blocked := []string{
|
||||||
|
"127.0.0.1", // loopback
|
||||||
|
"::1", // loopback v6
|
||||||
|
"::ffff:127.0.0.1", // loopback via IPv4-mapped v6
|
||||||
|
"10.0.0.5", // RFC1918
|
||||||
|
"172.16.4.2", // RFC1918
|
||||||
|
"192.168.1.1", // RFC1918
|
||||||
|
"::ffff:10.0.0.5", // RFC1918 via IPv4-mapped v6
|
||||||
|
"169.254.169.254", // cloud instance metadata
|
||||||
|
"fe80::1", // link-local v6
|
||||||
|
"fd00::1", // unique local v6
|
||||||
|
"0.0.0.0", // unspecified
|
||||||
|
"::", // unspecified v6
|
||||||
|
"224.0.0.1", // multicast
|
||||||
|
"100.64.0.1", // CGNAT
|
||||||
|
"198.18.0.1", // benchmarking
|
||||||
|
"240.0.0.1", // reserved
|
||||||
|
}
|
||||||
|
for _, s := range blocked {
|
||||||
|
if ip := net.ParseIP(s); ip == nil || !blockedIP(ip) {
|
||||||
|
t.Errorf("blockedIP(%s) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"}
|
||||||
|
for _, s := range allowed {
|
||||||
|
if ip := net.ParseIP(s); ip == nil || blockedIP(ip) {
|
||||||
|
t.Errorf("blockedIP(%s) = true, want false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hostname that resolves to a private address must be refused at dial time —
|
||||||
|
// this is what stops "http://internal.example.com" and DNS rebinding.
|
||||||
|
func TestFetch_RefusesPrivateAddresses(t *testing.T) {
|
||||||
|
f := New() // loopback NOT allowed
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://127.0.0.1:80/",
|
||||||
|
"http://localhost/",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://10.0.0.1/",
|
||||||
|
"http://[::1]/",
|
||||||
|
} {
|
||||||
|
_, err := f.Fetch(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Fetch(%s) succeeded — it must be refused", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_RejectsNonHTTPSchemes(t *testing.T) {
|
||||||
|
f := New()
|
||||||
|
for _, raw := range []string{
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"gopher://example.org/",
|
||||||
|
"ftp://example.org/",
|
||||||
|
"redis://10.0.0.1:6379",
|
||||||
|
"//example.org",
|
||||||
|
} {
|
||||||
|
if _, err := f.Fetch(context.Background(), raw); !errors.Is(err, ErrBlocked) {
|
||||||
|
t.Errorf("Fetch(%s) err = %v, want ErrBlocked", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testFetcher permits loopback so the fetch path can be exercised against an
|
||||||
|
// httptest server. The flag is unexported and set only here.
|
||||||
|
func testFetcher() *Fetcher {
|
||||||
|
f := New()
|
||||||
|
f.allowLoopback = true
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_ReadsOpenGraph(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, `<html><head>
|
||||||
|
<title>Fallback title</title>
|
||||||
|
<meta property="og:title" content="Bhagavad-gABC;tā As It Is">
|
||||||
|
<meta property="og:description" content="Chapter 2, verse 13">
|
||||||
|
<meta property="og:site_name" content="Prabhupāda Says">
|
||||||
|
<meta property="og:image" content="/img/cover.jpg">
|
||||||
|
</head><body>ignored</body></html>`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch: %v", err)
|
||||||
|
}
|
||||||
|
if p.Description != "Chapter 2, verse 13" {
|
||||||
|
t.Errorf("description = %q", p.Description)
|
||||||
|
}
|
||||||
|
if p.SiteName != "Prabhupāda Says" {
|
||||||
|
t.Errorf("site_name = %q", p.SiteName)
|
||||||
|
}
|
||||||
|
// Relative og:image must be resolved against the page it came from.
|
||||||
|
if !strings.HasPrefix(p.ImageURL, srv.URL) || !strings.HasSuffix(p.ImageURL, "/img/cover.jpg") {
|
||||||
|
t.Errorf("image = %q, want an absolute URL under %s", p.ImageURL, srv.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_FallsBackToTitleTag(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprint(w, `<html><head><title> Plain & simple </title>
|
||||||
|
<meta name="description" content="From the name attribute"></head></html>`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch: %v", err)
|
||||||
|
}
|
||||||
|
if p.Title != "Plain & simple" {
|
||||||
|
t.Errorf("title = %q, want entities decoded and whitespace collapsed", p.Title)
|
||||||
|
}
|
||||||
|
if p.Description != "From the name attribute" {
|
||||||
|
t.Errorf("description = %q", p.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redirect that lands on a private address must be refused at the hop, not
|
||||||
|
// followed because the first host looked fine.
|
||||||
|
func TestFetch_RefusesRedirectToPrivateAddress(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := testFetcher().Fetch(context.Background(), srv.URL); err == nil {
|
||||||
|
t.Fatal("followed a redirect into link-local space")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_RejectsNonHTML(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
fmt.Fprint(w, "%PDF-1.7")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
|
||||||
|
t.Errorf("err = %v, want ErrNotHTML", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page with no metadata is not a preview — better no card than an empty one.
|
||||||
|
func TestFetch_EmptyMetadataIsNotAPreview(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprint(w, `<html><head></head><body>nothing here</body></html>`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
|
||||||
|
t.Errorf("err = %v, want ErrNotHTML", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body far larger than the cap must not be read whole.
|
||||||
|
func TestFetch_CapsBodySize(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprint(w, `<html><head><meta property="og:title" content="Huge"></head><body>`)
|
||||||
|
chunk := strings.Repeat("x", 64<<10)
|
||||||
|
for i := 0; i < 64; i++ { // 4 MiB, well past maxBodyBytes
|
||||||
|
fmt.Fprint(w, chunk)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch: %v", err)
|
||||||
|
}
|
||||||
|
if p.Title != "Huge" {
|
||||||
|
t.Errorf("title = %q", p.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_LongFieldsAreTruncated(t *testing.T) {
|
||||||
|
long := strings.Repeat("ā", 500)
|
||||||
|
p := extract(`<meta property="og:title" content="` + long + `">`)
|
||||||
|
if len([]rune(p.Title)) > maxFieldRunes+1 {
|
||||||
|
t.Errorf("title kept %d runes, want it truncated", len([]rune(p.Title)))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(p.Title, "…") {
|
||||||
|
t.Error("truncated title should be marked with an ellipsis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_HandlesAttributeOrderAndQuotes(t *testing.T) {
|
||||||
|
p := extract(`
|
||||||
|
<meta content='Single quoted' property='og:title'>
|
||||||
|
<meta content=Unquoted name=og:site_name>
|
||||||
|
<meta property="og:description" content="Ordered normally">`)
|
||||||
|
if p.Title != "Single quoted" {
|
||||||
|
t.Errorf("title = %q", p.Title)
|
||||||
|
}
|
||||||
|
if p.SiteName != "Unquoted" {
|
||||||
|
t.Errorf("site_name = %q", p.SiteName)
|
||||||
|
}
|
||||||
|
if p.Description != "Ordered normally" {
|
||||||
|
t.Errorf("description = %q", p.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata is read from <head>; a <meta> forged in the body must not win.
|
||||||
|
func TestExtract_IgnoresBodyContent(t *testing.T) {
|
||||||
|
p := extract(`<html><head><meta property="og:title" content="Real"></head>
|
||||||
|
<body><meta property="og:title" content="Forged"></body></html>`)
|
||||||
|
if p.Title != "Real" {
|
||||||
|
t.Errorf("title = %q, want the head's value", p.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user