feat: disciple link

This commit is contained in:
Krispire Admin
2026-08-07 22:46:08 +05:30
parent 4553725d4e
commit afcdd7ef19
21 changed files with 863 additions and 23 deletions
+63
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
@@ -331,6 +332,68 @@ func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, ban
// { "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 {
+25 -2
View File
@@ -9,10 +9,33 @@ import (
type DiscipleHandler struct {
repo *repository.DiscipleRepo
// users (Postgres) resolves which disciples have a platform account
// linked by an admin. Separate store from the disciple records
// themselves, hence the second dependency.
users *repository.UserRepo
}
func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler {
return &DiscipleHandler{repo: repo}
func NewDiscipleHandler(repo *repository.DiscipleRepo, users *repository.UserRepo) *DiscipleHandler {
return &DiscipleHandler{repo: repo, users: users}
}
// Linked — GET /disciples/linked — every disciple that has a platform account,
// as {disciple_id, handle, name, avatar_url}. Public and unpaged: only Śrīla
// Prabhupāda's own disciples are ever linked, so the set is tiny and the
// /disciples browser fetches it once to highlight the matching cards.
func (h *DiscipleHandler) Linked(w http.ResponseWriter, r *http.Request) {
if h.users == nil {
// Degraded boot without Postgres — an empty set just means "no
// highlights", which is a fine reading experience.
response.JSON(w, http.StatusOK, []repository.DiscipleLink{})
return
}
links, err := h.users.ListDiscipleLinks(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "failed to fetch linked disciples")
return
}
response.JSON(w, http.StatusOK, links)
}
func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) {
+5
View File
@@ -686,6 +686,11 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
"following_count": following,
"relationship": rel,
"public_collections": cols,
// True when an admin has linked this account to a record in the
// disciples directory — i.e. one of Śrīla Prabhupāda's own initiated
// disciples. Drives the badge on the profile header. Only the boolean
// is exposed; the underlying Mongo id stays internal.
"is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "",
})
}
+8 -2
View File
@@ -16,6 +16,12 @@ import (
//
// It is READ-ONLY: feedback acknowledge/resolve actions live in the
// authenticated admin panel, never on this public page.
//
// PRIVACY: because the page is public, it must never render personal contact
// details. Anonymous submitters' email addresses are shown as "reply address
// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so
// the address isn't in memory here to leak by accident. Anything added to this
// template needs the same scrutiny; the admin panel is the place for PII.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
@@ -127,8 +133,8 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else if .HasContactEmail}}
anonymous · reply address on file
{{else}}
anonymous
{{end}}
+46
View File
@@ -0,0 +1,46 @@
package handler
import (
"strings"
"testing"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// Executes the public /stats template against representative rows and asserts
// no submitter email can appear in the output.
func TestStatsTemplateHidesEmails(t *testing.T) {
now := time.Now()
secret := "leak-me@example.com"
handle, name := "gopal", "Gopal Das"
rows := []model.Feedback{
// anonymous WITH a reply address on file
{ID: "1", Category: "bug", Message: "anon with email", CreatedAt: now,
HasContactEmail: true, ContactEmail: &secret},
// anonymous with no address at all
{ID: "2", Category: "general", Message: "anon no email", CreatedAt: now},
// authed submitter
{ID: "3", Category: "feature", Message: "authed", CreatedAt: now,
AuthorHandle: &handle, AuthorName: &name, AcknowledgedAt: &now, ResolvedAt: &now},
}
var sb strings.Builder
err := statsTpl.Execute(&sb, map[string]any{
"Stats": &repository.StatsSnapshot{Users: 1},
"Feedback": rows,
"GeneratedAt": "now",
})
if err != nil {
t.Fatalf("template execute failed: %v", err)
}
out := sb.String()
if strings.Contains(out, secret) || strings.Contains(out, "@example.com") {
t.Errorf("submitter email leaked into public /stats output")
}
for _, want := range []string{"reply address on file", "@gopal", "anon no email"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output", want)
}
}
}