feat: disciple link
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user